diff --git a/Sesame-Watch Watch App/ContentView.swift b/Sesame-Watch Watch App/ContentView.swift index 3fb9e7d..c1c5414 100644 --- a/Sesame-Watch Watch App/ContentView.swift +++ b/Sesame-Watch Watch App/ContentView.swift @@ -1,4 +1,5 @@ import SwiftUI +import SwiftData import SFSafeSymbols import CryptoKit @@ -7,73 +8,20 @@ struct ContentView: View { @Binding var didLaunchFromComplication: Bool - @AppStorage("connectionType") - var connectionType: ConnectionStrategy = .remoteFirst + @ObservedObject + var coordinator: RequestCoordinator - @AppStorage("server") - var serverPath: String = "https://christophhagen.de/sesame/" - - @AppStorage("localIP") - var localAddress: String = "192.168.178.104/" - - @AppStorage("counter") - var nextMessageCounter: Int = 0 - - @AppStorage("compensate") - var isCompensatingDaylightTime: Bool = false - - @AppStorage("deviceId") - private var deviceId: Int = 0 - - @EnvironmentObject - var keyManager: KeyManagement - - @EnvironmentObject - var history: HistoryManager - - @State - var state: ClientState = .noKeyAvailable - - @State - var stateResetTimer: Timer? - - let server = Client() - - private var firstTryIsLocalConnection: Bool { - switch connectionType { - case .local, .localFirst: - return true - case .remote, .remoteFirst: - return false - } - } - - private var hasSecondTry: Bool { - switch connectionType { - case .localFirst, .remoteFirst: - return true - default: - return false - } - } - - private var secondTryIsLocalConnection: Bool { - switch connectionType { - case .local, .localFirst: - return false - case .remote, .remoteFirst: - return true - } + init(coordinator: RequestCoordinator, didLaunchFromComplication: Binding) { + self._didLaunchFromComplication = didLaunchFromComplication + self.coordinator = coordinator } var buttonBackground: Color { - state.allowsAction ? - .white.opacity(0.2) : - .black.opacity(0.2) + .white.opacity(0.2) } var buttonColor: Color { - state.allowsAction ? .white : .gray + .white } var body: some View { @@ -85,145 +33,43 @@ struct ContentView: View { .aspectRatio(contentMode: .fit) .fontWeight(.ultraLight) .padding() - .onTapGesture(perform: mainButtonPressed) - .disabled(!state.allowsAction) - if state == .waitingForResponse { + .onTapGesture(perform: coordinator.startUnlock) + if coordinator.isPerformingRequest { ProgressView() .progressViewStyle(CircularProgressViewStyle()) .frame(width: 20, height: 20) } else { - Text(state.actionText) + Text("Unlock") .font(.subheadline) } } Spacer() } - .background(state.color) - .animation(.easeInOut, value: state.color) - .onAppear { - if state == .noKeyAvailable, keyManager.hasAllKeys { - state = .ready - } - } - .onChange(of: didLaunchFromComplication) { launched in + .background(coordinator.state.color) + .animation(.easeInOut, value: coordinator.state.color) + .onChange(of: didLaunchFromComplication) { _, launched in guard launched else { return } didLaunchFromComplication = false - mainButtonPressed() - } - } - - func mainButtonPressed() { - guard let keys = keyManager.getAllKeys(), - let deviceId = UInt8(exactly: deviceId) else { - return - } - sendMessage(from: deviceId, using: keys, isFirstTry: true) - } - - private func sendMessage(from deviceId: UInt8, using keys: KeySet, isFirstTry: Bool) { - preventStateReset() - state = .waitingForResponse - let localConnection = isFirstTry ? firstTryIsLocalConnection : secondTryIsLocalConnection - Task { - let response = await send( - count: UInt32(nextMessageCounter), - from: deviceId, - using: keys, - to: server, - over: localConnection, - while: isCompensatingDaylightTime, - localAddress: localAddress, - remoteAddress: serverPath) - - DispatchQueue.main.async { - state = response.response - scheduleStateReset() - if let counter = response.responseMessage?.id { - nextMessageCounter = Int(counter) - } - } - save(historyItem: response) - guard isFirstTry, hasSecondTry else { - return - } - DispatchQueue.main.async { - sendMessage(from: deviceId, using: keys, isFirstTry: false) - } - } - } - - private func preventStateReset() { - stateResetTimer?.invalidate() - stateResetTimer = nil - } - - private func scheduleStateReset() { - stateResetTimer?.invalidate() - stateResetTimer = Timer.scheduledTimer(withTimeInterval: 8.0, repeats: false) { _ in - DispatchQueue.main.async { - resetState() - } - } - } - - private func resetState() { - state = keyManager.hasAllKeys ? .ready : .noKeyAvailable - preventStateReset() - } - - private func save(historyItem: HistoryItem) { - do { - try history.save(item: historyItem) - } catch { - print("Failed to save item: \(error)") + coordinator.startUnlock() } } } -private func send(count: UInt32, from deviceId: UInt8, using keys: KeySet, to server: Client, over localConnection: Bool, while compensatingTime: Bool, localAddress: String, remoteAddress: String) async -> HistoryItem { - let sentTime = Date() - // Add time to compensate that the device is using daylight savings time - let timeCompensation: UInt32 = compensatingTime ? 3600 : 0 - let content = Message.Content( - time: sentTime.timestamp + timeCompensation, - id: count, - device: deviceId) - let message = content.authenticate(using: keys.remote) - print("Sending message \(count)") - let address = localConnection ? localAddress : remoteAddress - let (newState, responseMessage) = await send(message, to: server, using: keys.server, local: localConnection, address: address) - var historyItem = HistoryItem( - sent: message.content, - sentDate: sentTime, - local: localConnection, - response: newState, - responseDate: .now, - responseMessage: responseMessage?.content) - - guard let responseMessage else { - return historyItem - } - guard responseMessage.isValid(using: keys.device) else { - historyItem.response = .responseRejected(.invalidAuthentication) - return historyItem - } - return historyItem -} - -private func send(_ message: Message, to server: Client, using authToken: Data, local: Bool, address: String) async -> (state: ClientState, response: Message?) { - if local { - return await server.sendMessageOverLocalNetwork(message, server: address) - } else { - return await server.send(message, server: address, authToken: authToken) +#Preview { + do { + let config = ModelConfiguration(isStoredInMemoryOnly: true) + let container = try ModelContainer(for: HistoryItem.self, configurations: config) + + let item = HistoryItem.mock + container.mainContext.insert(item) + try container.mainContext.save() + let coordinator = RequestCoordinator(modelContext: container.mainContext) + return ContentView(coordinator: coordinator, didLaunchFromComplication: .constant(false)) + .modelContainer(container) + } catch { + fatalError("Failed to create model container.") } } -struct ContentView_Previews: PreviewProvider { - static var previews: some View { - ContentView(didLaunchFromComplication: .constant(false)) - .environmentObject(KeyManagement()) - .environmentObject(HistoryManager()) - } -} diff --git a/Sesame-Watch Watch App/HistoryItemDetail.swift b/Sesame-Watch Watch App/HistoryItemDetail.swift index 7b378e3..33d818f 100644 --- a/Sesame-Watch Watch App/HistoryItemDetail.swift +++ b/Sesame-Watch Watch App/HistoryItemDetail.swift @@ -1,4 +1,5 @@ import SwiftUI +import SwiftData import SFSafeSymbols private let df: DateFormatter = { @@ -10,27 +11,16 @@ private let df: DateFormatter = { }() struct HistoryItemDetail: View { + + @Environment(\.modelContext) + private var modelContext let item: HistoryItem - let history: HistoryManagerProtocol - @Environment(\.dismiss) private var dismiss private var entryTime: String { - df.string(from: item.requestDate) - } - - var counterText: String { - let sentCounter = item.request.id - let startText = "\(sentCounter)" - guard let rCounter = item.responseMessage?.id else { - return startText - } - guard sentCounter + 1 != rCounter && sentCounter != rCounter else { - return startText - } - return "\(sentCounter) -> \(rCounter)" + df.string(from: item.startDate) } var body: some View { @@ -43,21 +33,16 @@ struct HistoryItemDetail: View { value: entryTime) SettingsListTextItem( title: "Connection", - value: item.usedLocalConnection ? "Local" : "Remote") - SettingsListTextItem( - title: "Device ID", - value: "\(item.request.deviceId!)") - SettingsListTextItem( - title: "Message Counter", - value: counterText) + value: item.route.displayName) SettingsListTextItem( title: "Round Trip Time", value: "\(Int(item.roundTripTime * 1000)) ms") - if let offset = item.clockOffset { - SettingsListTextItem( - title: "Clock offset", - value: "\(offset) seconds") - } + SettingsListTextItem( + title: "Client challenge", + value: "\(item.message.clientChallenge)") + SettingsListTextItem( + title: "Server challenge", + value: "\(item.message.serverChallenge)") Button { delete(item: item) } label: { @@ -76,15 +61,22 @@ struct HistoryItemDetail: View { } private func delete(item: HistoryItem) { - guard history.delete(item: item) else { - return - } + modelContext.delete(item) dismiss() } } -struct HistoryItemDetail_Previews: PreviewProvider { - static var previews: some View { - HistoryItemDetail(item: .mock, history: HistoryManagerMock()) +#Preview { + do { + let config = ModelConfiguration(isStoredInMemoryOnly: true) + let container = try ModelContainer(for: HistoryItem.self, configurations: config) + + let item = HistoryItem.mock + container.mainContext.insert(item) + try container.mainContext.save() + return HistoryItemDetail(item: .mock) + .modelContainer(container) + } catch { + fatalError("Failed to create model container.") } } diff --git a/Sesame-Watch Watch App/HistoryListRow.swift b/Sesame-Watch Watch App/HistoryListRow.swift index 6022e7a..6b38f94 100644 --- a/Sesame-Watch Watch App/HistoryListRow.swift +++ b/Sesame-Watch Watch App/HistoryListRow.swift @@ -14,7 +14,7 @@ struct HistoryListRow: View { let item: HistoryItem private var entryTime: String { - df.string(from: item.requestDate) + df.string(from: item.startDate) } var body: some View { diff --git a/Sesame-Watch Watch App/HistoryView.swift b/Sesame-Watch Watch App/HistoryView.swift index fe9c17d..1828d66 100644 --- a/Sesame-Watch Watch App/HistoryView.swift +++ b/Sesame-Watch Watch App/HistoryView.swift @@ -1,21 +1,23 @@ import SwiftUI +import SwiftData struct HistoryView: View { + + @Environment(\.modelContext) + private var modelContext - @ObservedObject - var history: HistoryManager + @Query(sort: \HistoryItem.startDate, order: .reverse) + var history: [HistoryItem] = [] private var unlockCount: Int { - history.entries.count { - $0.response == .openSesame - } + history.count { $0.response == .unlocked } } private var percentage: Double { - guard history.entries.count > 0 else { + guard history.count > 0 else { return 0 } - return Double(unlockCount * 100) / Double(history.entries.count) + return Double(unlockCount * 100) / Double(history.count) } var body: some View { @@ -23,7 +25,7 @@ struct HistoryView: View { List { HStack { VStack(alignment: .leading) { - Text("\(history.entries.count) requests") + Text("\(history.count) requests") .foregroundColor(.primary) .font(.body) Text(String(format: "%.1f %% success", percentage)) @@ -34,9 +36,9 @@ struct HistoryView: View { } .listRowBackground(Color.clear) - ForEach(history.entries) { item in + ForEach(history) { item in NavigationLink { - HistoryItemDetail(item: item, history: history) + HistoryItemDetail(item: item) } label: { HistoryListRow(item: item) } @@ -54,14 +56,21 @@ struct HistoryView: View { } private func delete(item: HistoryItem) { - guard history.delete(item: item) else { - return - } + modelContext.delete(item) } } -struct HistoryView_Previews: PreviewProvider { - static var previews: some View { - HistoryView(history: HistoryManager()) +#Preview { + do { + let config = ModelConfiguration(isStoredInMemoryOnly: true) + let container = try ModelContainer(for: HistoryItem.self, configurations: config) + + let item = HistoryItem.mock + container.mainContext.insert(item) + try container.mainContext.save() + return HistoryView() + .modelContainer(container) + } catch { + fatalError("Failed to create model container.") } } diff --git a/Sesame-Watch Watch App/Sesame_WatchApp.swift b/Sesame-Watch Watch App/Sesame_WatchApp.swift index da6eac8..f0ceeec 100644 --- a/Sesame-Watch Watch App/Sesame_WatchApp.swift +++ b/Sesame-Watch Watch App/Sesame_WatchApp.swift @@ -1,29 +1,42 @@ import SwiftUI +import SwiftData @main struct Sesame_Watch_Watch_AppApp: App { + @State + var modelContainer: ModelContainer + + @ObservedObject + var coordinator: RequestCoordinator + let keyManagement = KeyManagement() - let history = HistoryManager() - @State var selected: Int = 0 @State var didLaunchFromComplication = false + init() { + do { + let modelContainer = try ModelContainer(for: HistoryItem.self) + self.modelContainer = modelContainer + self.coordinator = .init(modelContext: modelContainer.mainContext) + } catch { + fatalError("Failed to create model container: \(error)") + } + } + var body: some Scene { WindowGroup { TabView(selection: $selected) { - ContentView(didLaunchFromComplication: $didLaunchFromComplication) - .environmentObject(keyManagement) - .environmentObject(history) + ContentView(coordinator: coordinator, didLaunchFromComplication: $didLaunchFromComplication) .tag(1) SettingsView() .environmentObject(keyManagement) .tag(2) - HistoryView(history: history) + HistoryView() .tag(3) } .tabViewStyle(PageTabViewStyle()) @@ -32,5 +45,6 @@ struct Sesame_Watch_Watch_AppApp: App { didLaunchFromComplication = true } } + .modelContainer(modelContainer) } } diff --git a/Sesame-Watch Watch App/Settings/SettingsListToggleItem.swift b/Sesame-Watch Watch App/Settings/SettingsListToggleItem.swift deleted file mode 100644 index 4f9c940..0000000 --- a/Sesame-Watch Watch App/Settings/SettingsListToggleItem.swift +++ /dev/null @@ -1,28 +0,0 @@ -import SwiftUI - -struct SettingsListToggleItem: View { - - let title: String - - @Binding - var value: Bool - - let subtitle: String - - var body: some View { - VStack(alignment: .leading) { - Toggle(title, isOn: $value) - Text(subtitle) - .font(.footnote) - .foregroundColor(.secondary) - } - .padding() - .cornerRadius(8) - } -} - -struct SettingsListToggleItem_Previews: PreviewProvider { - static var previews: some View { - SettingsListToggleItem(title: "Toggle", value: .constant(true), subtitle: "Some longer text explaining what the toggle does") - } -} diff --git a/Sesame-Watch Watch App/SettingsView.swift b/Sesame-Watch Watch App/SettingsView.swift index d3647cc..190efc7 100644 --- a/Sesame-Watch Watch App/SettingsView.swift +++ b/Sesame-Watch Watch App/SettingsView.swift @@ -11,29 +11,22 @@ struct SettingsView: View { @AppStorage("localIP") var localAddress: String = "192.168.178.104/" - @AppStorage("counter") - var nextMessageCounter: Int = 0 - - @AppStorage("compensate") - var isCompensatingDaylightTime: Bool = false - - @AppStorage("deviceId") - private var deviceId: Int = 0 - @EnvironmentObject var keys: KeyManagement + + var some: String { "some" } var body: some View { NavigationStack { List { Picker("Connection", selection: $connectionType) { - Text(ConnectionStrategy.local.rawValue) + Text(display: ConnectionStrategy.local) .tag(ConnectionStrategy.local) - Text(ConnectionStrategy.localFirst.rawValue) + Text(display: ConnectionStrategy.localFirst) .tag(ConnectionStrategy.localFirst) - Text(ConnectionStrategy.remote.rawValue) + Text(display: ConnectionStrategy.remote) .tag(ConnectionStrategy.remote) - Text(ConnectionStrategy.remoteFirst.rawValue) + Text(display: ConnectionStrategy.remoteFirst) .tag(ConnectionStrategy.remoteFirst) } .padding(.leading) @@ -45,18 +38,6 @@ struct SettingsView: View { title: "Local url", value: $localAddress, footnote: "The url where the device can be reached directly on the local WiFi network.") - SettingsNumberItemLink( - title: "Device ID", - value: $deviceId, - footnote: "The device ID is unique for each remote device, and is assigned by the system administrator.") - SettingsNumberItemLink( - title: "Message counter", - value: $nextMessageCounter, - footnote: "The message counter is increased after every message to the device, and used to prevent replay attacks.") - SettingsListToggleItem( - title: "Daylight savings", - value: $isCompensatingDaylightTime, - subtitle: "Compensate timestamps if the remote has daylight savings time wrongly set.") SettingsKeyItemLink( type: .deviceKey, footnote: "Some text describing the purpose of the key.") diff --git a/Sesame.xcodeproj/project.pbxproj b/Sesame.xcodeproj/project.pbxproj index 2306fab..84ed9df 100644 --- a/Sesame.xcodeproj/project.pbxproj +++ b/Sesame.xcodeproj/project.pbxproj @@ -11,32 +11,61 @@ 884A45B9279F48C100D6E650 /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 884A45B8279F48C100D6E650 /* ContentView.swift */; }; 884A45BB279F48C300D6E650 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 884A45BA279F48C300D6E650 /* Assets.xcassets */; }; 884A45C5279F4BBE00D6E650 /* KeyManagement.swift in Sources */ = {isa = PBXBuildFile; fileRef = 884A45C4279F4BBE00D6E650 /* KeyManagement.swift */; }; - 884A45C927A43D7900D6E650 /* ClientState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 884A45C827A43D7900D6E650 /* ClientState.swift */; }; 884A45CB27A464C000D6E650 /* SymmetricKey+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 884A45CA27A464C000D6E650 /* SymmetricKey+Extensions.swift */; }; 884A45CF27A5402D00D6E650 /* MessageResult.swift in Sources */ = {isa = PBXBuildFile; fileRef = 884A45CE27A5402D00D6E650 /* MessageResult.swift */; }; + 8860D7432B22858600849FAC /* Date+Timestamp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8860D7422B22858600849FAC /* Date+Timestamp.swift */; }; + 8860D7462B2328EC00849FAC /* Message+Size.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8860D7452B2328EC00849FAC /* Message+Size.swift */; }; + 8860D7482B23294600849FAC /* SignedMessage+Crypto.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8860D7472B23294600849FAC /* SignedMessage+Crypto.swift */; }; + 8860D74A2B2329CE00849FAC /* SignedMessage+Size.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8860D7492B2329CE00849FAC /* SignedMessage+Size.swift */; }; + 8860D74C2B232A7700849FAC /* SesameHeader.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8860D74B2B232A7700849FAC /* SesameHeader.swift */; }; + 8860D74E2B232AED00849FAC /* Data+Coding.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8860D74D2B232AED00849FAC /* Data+Coding.swift */; }; + 8860D7522B233BEA00849FAC /* TransmissionType.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8860D7512B233BEA00849FAC /* TransmissionType.swift */; }; + 8860D7542B23489300849FAC /* ActiveRequestType.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8860D7532B23489300849FAC /* ActiveRequestType.swift */; }; + 8860D7552B237F9100849FAC /* TransmissionType.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8860D7512B233BEA00849FAC /* TransmissionType.swift */; }; + 8860D7562B237F9400849FAC /* ActiveRequestType.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8860D7532B23489300849FAC /* ActiveRequestType.swift */; }; + 8860D7572B237FAD00849FAC /* MessageType.swift in Sources */ = {isa = PBXBuildFile; fileRef = 88AEE37E2B2217050034EDA9 /* MessageType.swift */; }; + 8860D7582B237FB000849FAC /* Message.swift in Sources */ = {isa = PBXBuildFile; fileRef = E24EE77827FF95E00011CFD2 /* Message.swift */; }; + 8860D7592B237FB200849FAC /* Message+Crypto.swift in Sources */ = {isa = PBXBuildFile; fileRef = 88AEE3852B22376D0034EDA9 /* Message+Crypto.swift */; }; + 8860D75A2B237FB400849FAC /* SignedMessage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 88AEE3832B2236DC0034EDA9 /* SignedMessage.swift */; }; + 8860D75B2B237FB600849FAC /* SignedMessage+Crypto.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8860D7472B23294600849FAC /* SignedMessage+Crypto.swift */; }; + 8860D75C2B237FB900849FAC /* MessageResult+UI.swift in Sources */ = {isa = PBXBuildFile; fileRef = 88AEE3872B226FED0034EDA9 /* MessageResult+UI.swift */; }; + 8860D75D2B237FC000849FAC /* Data+Coding.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8860D74D2B232AED00849FAC /* Data+Coding.swift */; }; + 8860D75E2B237FC600849FAC /* Message+Size.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8860D7452B2328EC00849FAC /* Message+Size.swift */; }; + 8860D75F2B237FC900849FAC /* SignedMessage+Size.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8860D7492B2329CE00849FAC /* SignedMessage+Size.swift */; }; + 8860D7602B237FCC00849FAC /* SesameHeader.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8860D74B2B232A7700849FAC /* SesameHeader.swift */; }; + 8860D7622B23803E00849FAC /* ServerChallenge.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8860D7612B23803E00849FAC /* ServerChallenge.swift */; }; + 8860D7632B23803E00849FAC /* ServerChallenge.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8860D7612B23803E00849FAC /* ServerChallenge.swift */; }; + 8860D7652B23B5B200849FAC /* RequestCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8860D7642B23B5B200849FAC /* RequestCoordinator.swift */; }; + 8860D7662B23B5B200849FAC /* RequestCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8860D7642B23B5B200849FAC /* RequestCoordinator.swift */; }; + 8860D7682B23D04100849FAC /* PendingOperation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8860D7672B23D04100849FAC /* PendingOperation.swift */; }; + 8860D7692B23D04100849FAC /* PendingOperation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8860D7672B23D04100849FAC /* PendingOperation.swift */; }; + 8860D76C2B246F5E00849FAC /* UInt32+Random.swift in Sources */ = {isa = PBXBuildFile; fileRef = 88AEE3802B22327F0034EDA9 /* UInt32+Random.swift */; }; + 8860D76E2B246FC400849FAC /* Text+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8860D76D2B246FC400849FAC /* Text+Extensions.swift */; }; + 8860D76F2B246FC400849FAC /* Text+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8860D76D2B246FC400849FAC /* Text+Extensions.swift */; }; 8864664F29E5684C004FE2BE /* CBORCoding in Frameworks */ = {isa = PBXBuildFile; productRef = 8864664E29E5684C004FE2BE /* CBORCoding */; }; 8864665229E5939C004FE2BE /* SFSafeSymbols in Frameworks */ = {isa = PBXBuildFile; productRef = 8864665129E5939C004FE2BE /* SFSafeSymbols */; }; 888362342A80F3F90032BBB2 /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 888362332A80F3F90032BBB2 /* SettingsView.swift */; }; 888362362A80F4420032BBB2 /* HistoryView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 888362352A80F4420032BBB2 /* HistoryView.swift */; }; + 88AEE37F2B2217050034EDA9 /* MessageType.swift in Sources */ = {isa = PBXBuildFile; fileRef = 88AEE37E2B2217050034EDA9 /* MessageType.swift */; }; + 88AEE3812B22327F0034EDA9 /* UInt32+Random.swift in Sources */ = {isa = PBXBuildFile; fileRef = 88AEE3802B22327F0034EDA9 /* UInt32+Random.swift */; }; + 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 */; }; 88E197B229EDC9BC00BF1D19 /* Sesame_WatchApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 88E197B129EDC9BC00BF1D19 /* Sesame_WatchApp.swift */; }; 88E197B429EDC9BC00BF1D19 /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 88E197B329EDC9BC00BF1D19 /* ContentView.swift */; }; 88E197B629EDC9BD00BF1D19 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 88E197B529EDC9BD00BF1D19 /* Assets.xcassets */; }; 88E197C429EDCC8900BF1D19 /* Client.swift in Sources */ = {isa = PBXBuildFile; fileRef = 884A45CC27A465F500D6E650 /* Client.swift */; }; 88E197C729EDCCBD00BF1D19 /* Client.swift in Sources */ = {isa = PBXBuildFile; fileRef = 884A45CC27A465F500D6E650 /* Client.swift */; }; - 88E197C829EDCCCE00BF1D19 /* ClientState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 884A45C827A43D7900D6E650 /* ClientState.swift */; }; - 88E197C929EDCCE100BF1D19 /* Message.swift in Sources */ = {isa = PBXBuildFile; fileRef = E24EE77827FF95E00011CFD2 /* Message.swift */; }; 88E197CC29EDCD4900BF1D19 /* NIOCore in Frameworks */ = {isa = PBXBuildFile; productRef = 88E197CB29EDCD4900BF1D19 /* NIOCore */; }; 88E197CE29EDCD7500BF1D19 /* CBORCoding in Frameworks */ = {isa = PBXBuildFile; productRef = 88E197CD29EDCD7500BF1D19 /* CBORCoding */; }; 88E197D029EDCD7D00BF1D19 /* SFSafeSymbols in Frameworks */ = {isa = PBXBuildFile; productRef = 88E197CF29EDCD7D00BF1D19 /* SFSafeSymbols */; }; - 88E197D129EDCE5F00BF1D19 /* Data+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = E24EE77127FDCCC00011CFD2 /* Data+Extensions.swift */; }; - 88E197D229EDCE6600BF1D19 /* RouteAPI.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2C5C1DA2806FE8900769EF6 /* RouteAPI.swift */; }; + 88E197D129EDCE5F00BF1D19 /* Data+Hex.swift in Sources */ = {isa = PBXBuildFile; fileRef = E24EE77127FDCCC00011CFD2 /* Data+Hex.swift */; }; + 88E197D229EDCE6600BF1D19 /* SesameRoute.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2C5C1DA2806FE8900769EF6 /* SesameRoute.swift */; }; 88E197D329EDCE6E00BF1D19 /* MessageResult.swift in Sources */ = {isa = PBXBuildFile; fileRef = 884A45CE27A5402D00D6E650 /* MessageResult.swift */; }; - 88E197D429EDCE7600BF1D19 /* UInt32+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2C5C1DC281B3AC400769EF6 /* UInt32+Extensions.swift */; }; - 88E197D529EDCE8800BF1D19 /* ServerMessage.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2C5C1F7281E769F00769EF6 /* ServerMessage.swift */; }; + 88E197D429EDCE7600BF1D19 /* UInt32+Coding.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2C5C1DC281B3AC400769EF6 /* UInt32+Coding.swift */; }; 88E197D729EDCFE800BF1D19 /* Date+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 88E197D629EDCFE800BF1D19 /* Date+Extensions.swift */; }; 88E197D829EDD13B00BF1D19 /* SymmetricKey+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 884A45CA27A464C000D6E650 /* SymmetricKey+Extensions.swift */; }; E240654B2A8153C6009C1AD8 /* SettingsListTextItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = E240654A2A8153C6009C1AD8 /* SettingsListTextItem.swift */; }; - E240654D2A8155A3009C1AD8 /* SettingsListToggleItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = E240654C2A8155A3009C1AD8 /* SettingsListToggleItem.swift */; }; E240654F2A8159B7009C1AD8 /* SettingsTextInputView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E240654E2A8159B7009C1AD8 /* SettingsTextInputView.swift */; }; E24065512A819066009C1AD8 /* SettingsTextItemLink.swift in Sources */ = {isa = PBXBuildFile; fileRef = E24065502A819066009C1AD8 /* SettingsTextItemLink.swift */; }; E24065532A819614009C1AD8 /* SettingsNumberItemLink.swift in Sources */ = {isa = PBXBuildFile; fileRef = E24065522A819614009C1AD8 /* SettingsNumberItemLink.swift */; }; @@ -47,8 +76,7 @@ E240655C2A822C8E009C1AD8 /* HistoryManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = E28DED36281EC7FB00259690 /* HistoryManager.swift */; }; E240655E2A822E97009C1AD8 /* HistoryListRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = E240655D2A822E97009C1AD8 /* HistoryListRow.swift */; }; E24065602A822ED9009C1AD8 /* HistoryItemDetail.swift in Sources */ = {isa = PBXBuildFile; fileRef = E240655F2A822ED9009C1AD8 /* HistoryItemDetail.swift */; }; - E24EE77227FDCCC00011CFD2 /* Data+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = E24EE77127FDCCC00011CFD2 /* Data+Extensions.swift */; }; - E24EE77427FF95920011CFD2 /* DeviceResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = E24EE77327FF95920011CFD2 /* DeviceResponse.swift */; }; + E24EE77227FDCCC00011CFD2 /* Data+Hex.swift in Sources */ = {isa = PBXBuildFile; fileRef = E24EE77127FDCCC00011CFD2 /* Data+Hex.swift */; }; E24EE77727FF95C00011CFD2 /* NIOCore in Frameworks */ = {isa = PBXBuildFile; productRef = E24EE77627FF95C00011CFD2 /* NIOCore */; }; E24EE77927FF95E00011CFD2 /* Message.swift in Sources */ = {isa = PBXBuildFile; fileRef = E24EE77827FF95E00011CFD2 /* Message.swift */; }; E24F6C6E2A89749A0040F8C4 /* ConnectionStrategy.swift in Sources */ = {isa = PBXBuildFile; fileRef = E24F6C6D2A89749A0040F8C4 /* ConnectionStrategy.swift */; }; @@ -66,9 +94,8 @@ E28DED33281EB15B00259690 /* HistoryListItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = E28DED32281EB15B00259690 /* HistoryListItem.swift */; }; E28DED35281EB17600259690 /* HistoryItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = E28DED34281EB17600259690 /* HistoryItem.swift */; }; E28DED37281EC7FB00259690 /* HistoryManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = E28DED36281EC7FB00259690 /* HistoryManager.swift */; }; - E2C5C1DB2806FE8900769EF6 /* RouteAPI.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2C5C1DA2806FE8900769EF6 /* RouteAPI.swift */; }; - E2C5C1DD281B3AC400769EF6 /* UInt32+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2C5C1DC281B3AC400769EF6 /* UInt32+Extensions.swift */; }; - E2C5C1F8281E769F00769EF6 /* ServerMessage.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2C5C1F7281E769F00769EF6 /* ServerMessage.swift */; }; + E2C5C1DB2806FE8900769EF6 /* SesameRoute.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2C5C1DA2806FE8900769EF6 /* SesameRoute.swift */; }; + E2C5C1DD281B3AC400769EF6 /* UInt32+Coding.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2C5C1DC281B3AC400769EF6 /* UInt32+Coding.swift */; }; E2F5DCCA2A88E913002858B9 /* Array+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2F5DCC92A88E913002858B9 /* Array+Extensions.swift */; }; E2F5DCCB2A88E976002858B9 /* Array+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2F5DCC92A88E913002858B9 /* Array+Extensions.swift */; }; /* End PBXBuildFile section */ @@ -103,19 +130,34 @@ 884A45B8279F48C100D6E650 /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = ""; }; 884A45BA279F48C300D6E650 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; 884A45C4279F4BBE00D6E650 /* KeyManagement.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeyManagement.swift; sourceTree = ""; }; - 884A45C827A43D7900D6E650 /* ClientState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ClientState.swift; sourceTree = ""; }; 884A45CA27A464C000D6E650 /* SymmetricKey+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SymmetricKey+Extensions.swift"; sourceTree = ""; }; 884A45CC27A465F500D6E650 /* Client.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Client.swift; sourceTree = ""; }; 884A45CE27A5402D00D6E650 /* MessageResult.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageResult.swift; sourceTree = ""; }; + 8860D7422B22858600849FAC /* Date+Timestamp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Date+Timestamp.swift"; sourceTree = ""; }; + 8860D7452B2328EC00849FAC /* Message+Size.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Message+Size.swift"; sourceTree = ""; }; + 8860D7472B23294600849FAC /* SignedMessage+Crypto.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SignedMessage+Crypto.swift"; sourceTree = ""; }; + 8860D7492B2329CE00849FAC /* SignedMessage+Size.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SignedMessage+Size.swift"; sourceTree = ""; }; + 8860D74B2B232A7700849FAC /* SesameHeader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SesameHeader.swift; sourceTree = ""; }; + 8860D74D2B232AED00849FAC /* Data+Coding.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Data+Coding.swift"; sourceTree = ""; }; + 8860D7512B233BEA00849FAC /* TransmissionType.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TransmissionType.swift; sourceTree = ""; }; + 8860D7532B23489300849FAC /* ActiveRequestType.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ActiveRequestType.swift; sourceTree = ""; }; + 8860D7612B23803E00849FAC /* ServerChallenge.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerChallenge.swift; sourceTree = ""; }; + 8860D7642B23B5B200849FAC /* RequestCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RequestCoordinator.swift; sourceTree = ""; }; + 8860D7672B23D04100849FAC /* PendingOperation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PendingOperation.swift; sourceTree = ""; }; + 8860D76D2B246FC400849FAC /* Text+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Text+Extensions.swift"; sourceTree = ""; }; 888362332A80F3F90032BBB2 /* SettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsView.swift; sourceTree = ""; }; 888362352A80F4420032BBB2 /* HistoryView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HistoryView.swift; sourceTree = ""; }; + 88AEE37E2B2217050034EDA9 /* MessageType.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageType.swift; sourceTree = ""; }; + 88AEE3802B22327F0034EDA9 /* UInt32+Random.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UInt32+Random.swift"; sourceTree = ""; }; + 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 = ""; }; 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 /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = ""; }; 88E197B529EDC9BD00BF1D19 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; 88E197D629EDCFE800BF1D19 /* Date+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Date+Extensions.swift"; sourceTree = ""; }; E240654A2A8153C6009C1AD8 /* SettingsListTextItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsListTextItem.swift; sourceTree = ""; }; - E240654C2A8155A3009C1AD8 /* SettingsListToggleItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsListToggleItem.swift; sourceTree = ""; }; E240654E2A8159B7009C1AD8 /* SettingsTextInputView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsTextInputView.swift; sourceTree = ""; }; E24065502A819066009C1AD8 /* SettingsTextItemLink.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsTextItemLink.swift; sourceTree = ""; }; E24065522A819614009C1AD8 /* SettingsNumberItemLink.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsNumberItemLink.swift; sourceTree = ""; }; @@ -124,8 +166,7 @@ E24065592A82218D009C1AD8 /* SettingsKeyInputView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsKeyInputView.swift; sourceTree = ""; }; E240655D2A822E97009C1AD8 /* HistoryListRow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HistoryListRow.swift; sourceTree = ""; }; E240655F2A822ED9009C1AD8 /* HistoryItemDetail.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HistoryItemDetail.swift; sourceTree = ""; }; - E24EE77127FDCCC00011CFD2 /* Data+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Data+Extensions.swift"; sourceTree = ""; }; - E24EE77327FF95920011CFD2 /* DeviceResponse.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeviceResponse.swift; sourceTree = ""; }; + E24EE77127FDCCC00011CFD2 /* Data+Hex.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Data+Hex.swift"; sourceTree = ""; }; E24EE77827FF95E00011CFD2 /* Message.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Message.swift; sourceTree = ""; }; E24F6C6D2A89749A0040F8C4 /* ConnectionStrategy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConnectionStrategy.swift; sourceTree = ""; }; E268E0532A852F8E00185913 /* WidgetKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = WidgetKit.framework; path = System/Library/Frameworks/WidgetKit.framework; sourceTree = SDKROOT; }; @@ -141,9 +182,8 @@ E28DED34281EB17600259690 /* HistoryItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HistoryItem.swift; sourceTree = ""; }; E28DED36281EC7FB00259690 /* HistoryManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HistoryManager.swift; sourceTree = ""; }; E28DED38281EE9CF00259690 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = ""; }; - E2C5C1DA2806FE8900769EF6 /* RouteAPI.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RouteAPI.swift; sourceTree = ""; }; - E2C5C1DC281B3AC400769EF6 /* UInt32+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UInt32+Extensions.swift"; sourceTree = ""; }; - E2C5C1F7281E769F00769EF6 /* ServerMessage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerMessage.swift; sourceTree = ""; }; + E2C5C1DA2806FE8900769EF6 /* SesameRoute.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SesameRoute.swift; sourceTree = ""; }; + E2C5C1DC281B3AC400769EF6 /* UInt32+Coding.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UInt32+Coding.swift"; sourceTree = ""; }; E2F5DCC92A88E913002858B9 /* Array+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Array+Extensions.swift"; sourceTree = ""; }; /* End PBXFileReference section */ @@ -209,17 +249,51 @@ 884A45BA279F48C300D6E650 /* Assets.xcassets */, E24F6C6C2A89748B0040F8C4 /* Common */, E2C5C1D92806FE4A00769EF6 /* API */, + 8860D7442B2328B800849FAC /* API Extensions */, 884A45B6279F48C100D6E650 /* SesameApp.swift */, 884A45B8279F48C100D6E650 /* ContentView.swift */, E28DED2C281E840B00259690 /* SettingsView.swift */, E28DED2E281E8A0500259690 /* SingleKeyView.swift */, - E28DED30281EAE9100259690 /* HistoryView.swift */, E25317542A8A1A07005A537D /* History */, E25317552A8A1A32005A537D /* Extensions */, ); path = Sesame; sourceTree = ""; }; + 8860D7442B2328B800849FAC /* API Extensions */ = { + isa = PBXGroup; + children = ( + 88AEE3872B226FED0034EDA9 /* MessageResult+UI.swift */, + 88AEE37E2B2217050034EDA9 /* MessageType.swift */, + E24EE77827FF95E00011CFD2 /* Message.swift */, + 88AEE3852B22376D0034EDA9 /* Message+Crypto.swift */, + 88AEE3832B2236DC0034EDA9 /* SignedMessage.swift */, + 8860D7472B23294600849FAC /* SignedMessage+Crypto.swift */, + ); + path = "API Extensions"; + sourceTree = ""; + }; + 8860D76B2B246F5600849FAC /* Extensions */ = { + isa = PBXGroup; + children = ( + E2F5DCC92A88E913002858B9 /* Array+Extensions.swift */, + 884A45CA27A464C000D6E650 /* SymmetricKey+Extensions.swift */, + 88AEE3802B22327F0034EDA9 /* UInt32+Random.swift */, + 8860D76D2B246FC400849FAC /* Text+Extensions.swift */, + ); + path = Extensions; + sourceTree = ""; + }; + 88AEE3822B22331E0034EDA9 /* Extensions */ = { + isa = PBXGroup; + children = ( + E24EE77127FDCCC00011CFD2 /* Data+Hex.swift */, + 8860D74D2B232AED00849FAC /* Data+Coding.swift */, + E2C5C1DC281B3AC400769EF6 /* UInt32+Coding.swift */, + ); + path = Extensions; + sourceTree = ""; + }; 88E197B029EDC9BC00BF1D19 /* Sesame-Watch Watch App */ = { isa = PBXGroup; children = ( @@ -253,7 +327,6 @@ E24065542A819663009C1AD8 /* SettingsNumberInputView.swift */, E240654E2A8159B7009C1AD8 /* SettingsTextInputView.swift */, E240654A2A8153C6009C1AD8 /* SettingsListTextItem.swift */, - E240654C2A8155A3009C1AD8 /* SettingsListToggleItem.swift */, E24065572A819AE3009C1AD8 /* SettingsKeyItemLink.swift */, E24065592A82218D009C1AD8 /* SettingsKeyInputView.swift */, ); @@ -263,11 +336,16 @@ E24F6C6C2A89748B0040F8C4 /* Common */ = { isa = PBXGroup; children = ( + 8860D76B2B246F5600849FAC /* Extensions */, 884A45CC27A465F500D6E650 /* Client.swift */, - 884A45C827A43D7900D6E650 /* ClientState.swift */, E24F6C6D2A89749A0040F8C4 /* ConnectionStrategy.swift */, E28DED36281EC7FB00259690 /* HistoryManager.swift */, 884A45C4279F4BBE00D6E650 /* KeyManagement.swift */, + 8860D7512B233BEA00849FAC /* TransmissionType.swift */, + 8860D7532B23489300849FAC /* ActiveRequestType.swift */, + 8860D7612B23803E00849FAC /* ServerChallenge.swift */, + 8860D7642B23B5B200849FAC /* RequestCoordinator.swift */, + 8860D7672B23D04100849FAC /* PendingOperation.swift */, ); path = Common; sourceTree = ""; @@ -275,6 +353,7 @@ E25317542A8A1A07005A537D /* History */ = { isa = PBXGroup; children = ( + E28DED30281EAE9100259690 /* HistoryView.swift */, E28DED32281EB15B00259690 /* HistoryListItem.swift */, E28DED34281EB17600259690 /* HistoryItem.swift */, ); @@ -284,8 +363,7 @@ E25317552A8A1A32005A537D /* Extensions */ = { isa = PBXGroup; children = ( - E2F5DCC92A88E913002858B9 /* Array+Extensions.swift */, - 884A45CA27A464C000D6E650 /* SymmetricKey+Extensions.swift */, + 8860D7422B22858600849FAC /* Date+Timestamp.swift */, ); path = Extensions; sourceTree = ""; @@ -303,13 +381,12 @@ E2C5C1D92806FE4A00769EF6 /* API */ = { isa = PBXGroup; children = ( - E24EE77127FDCCC00011CFD2 /* Data+Extensions.swift */, - E24EE77327FF95920011CFD2 /* DeviceResponse.swift */, - E24EE77827FF95E00011CFD2 /* Message.swift */, + 88AEE3822B22331E0034EDA9 /* Extensions */, 884A45CE27A5402D00D6E650 /* MessageResult.swift */, - E2C5C1DA2806FE8900769EF6 /* RouteAPI.swift */, - E2C5C1F7281E769F00769EF6 /* ServerMessage.swift */, - E2C5C1DC281B3AC400769EF6 /* UInt32+Extensions.swift */, + E2C5C1DA2806FE8900769EF6 /* SesameRoute.swift */, + 8860D7452B2328EC00849FAC /* Message+Size.swift */, + 8860D7492B2329CE00849FAC /* SignedMessage+Size.swift */, + 8860D74B2B232A7700849FAC /* SesameHeader.swift */, ); path = API; sourceTree = ""; @@ -391,7 +468,7 @@ attributes = { BuildIndependentTargetsInParallel = 1; LastSwiftUpdateCheck = 1430; - LastUpgradeCheck = 1320; + LastUpgradeCheck = 1500; TargetAttributes = { 884A45B2279F48C100D6E650 = { CreatedOnToolsVersion = 13.2.1; @@ -464,25 +541,39 @@ files = ( 884A45CF27A5402D00D6E650 /* MessageResult.swift in Sources */, 884A45B9279F48C100D6E650 /* ContentView.swift in Sources */, + 88AEE3882B226FED0034EDA9 /* MessageResult+UI.swift in Sources */, E28DED2F281E8A0500259690 /* SingleKeyView.swift in Sources */, E28DED37281EC7FB00259690 /* HistoryManager.swift in Sources */, - E2C5C1DB2806FE8900769EF6 /* RouteAPI.swift in Sources */, - E2C5C1DD281B3AC400769EF6 /* UInt32+Extensions.swift in Sources */, - E24EE77227FDCCC00011CFD2 /* Data+Extensions.swift in Sources */, - E24EE77427FF95920011CFD2 /* DeviceResponse.swift in Sources */, + E2C5C1DB2806FE8900769EF6 /* SesameRoute.swift in Sources */, + E2C5C1DD281B3AC400769EF6 /* UInt32+Coding.swift in Sources */, + E24EE77227FDCCC00011CFD2 /* Data+Hex.swift in Sources */, + 8860D7482B23294600849FAC /* SignedMessage+Crypto.swift in Sources */, + 8860D74C2B232A7700849FAC /* SesameHeader.swift in Sources */, + 8860D7622B23803E00849FAC /* ServerChallenge.swift in Sources */, + 8860D7432B22858600849FAC /* Date+Timestamp.swift in Sources */, + 88AEE3862B22376D0034EDA9 /* Message+Crypto.swift in Sources */, + 8860D7682B23D04100849FAC /* PendingOperation.swift in Sources */, + 8860D74E2B232AED00849FAC /* Data+Coding.swift in Sources */, + 8860D7522B233BEA00849FAC /* TransmissionType.swift in Sources */, 884A45CB27A464C000D6E650 /* SymmetricKey+Extensions.swift in Sources */, E28DED31281EAE9100259690 /* HistoryView.swift in Sources */, E24EE77927FF95E00011CFD2 /* Message.swift in Sources */, E2F5DCCA2A88E913002858B9 /* Array+Extensions.swift in Sources */, E28DED35281EB17600259690 /* HistoryItem.swift in Sources */, - 884A45C927A43D7900D6E650 /* ClientState.swift in Sources */, E28DED33281EB15B00259690 /* HistoryListItem.swift in Sources */, E28DED2D281E840B00259690 /* SettingsView.swift in Sources */, 884A45B7279F48C100D6E650 /* SesameApp.swift in Sources */, + 8860D76E2B246FC400849FAC /* Text+Extensions.swift in Sources */, 88E197C429EDCC8900BF1D19 /* Client.swift in Sources */, + 8860D7652B23B5B200849FAC /* RequestCoordinator.swift in Sources */, + 88AEE3812B22327F0034EDA9 /* UInt32+Random.swift in Sources */, E24F6C6E2A89749A0040F8C4 /* ConnectionStrategy.swift in Sources */, 884A45C5279F4BBE00D6E650 /* KeyManagement.swift in Sources */, - E2C5C1F8281E769F00769EF6 /* ServerMessage.swift in Sources */, + 88AEE3842B2236DC0034EDA9 /* SignedMessage.swift in Sources */, + 8860D74A2B2329CE00849FAC /* SignedMessage+Size.swift in Sources */, + 8860D7542B23489300849FAC /* ActiveRequestType.swift in Sources */, + 88AEE37F2B2217050034EDA9 /* MessageType.swift in Sources */, + 8860D7462B2328EC00849FAC /* Message+Size.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -490,33 +581,46 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + 8860D7662B23B5B200849FAC /* RequestCoordinator.swift in Sources */, + 8860D75D2B237FC000849FAC /* Data+Coding.swift in Sources */, 888362342A80F3F90032BBB2 /* SettingsView.swift in Sources */, 88E197B429EDC9BC00BF1D19 /* ContentView.swift in Sources */, E2F5DCCB2A88E976002858B9 /* Array+Extensions.swift in Sources */, E24065532A819614009C1AD8 /* SettingsNumberItemLink.swift in Sources */, 888362362A80F4420032BBB2 /* HistoryView.swift in Sources */, + 8860D75B2B237FB600849FAC /* SignedMessage+Crypto.swift in Sources */, E240654F2A8159B7009C1AD8 /* SettingsTextInputView.swift in Sources */, 88E197D329EDCE6E00BF1D19 /* MessageResult.swift in Sources */, - 88E197D529EDCE8800BF1D19 /* ServerMessage.swift in Sources */, - 88E197D129EDCE5F00BF1D19 /* Data+Extensions.swift in Sources */, + 88E197D129EDCE5F00BF1D19 /* Data+Hex.swift in Sources */, + 8860D76C2B246F5E00849FAC /* UInt32+Random.swift in Sources */, E240655A2A82218D009C1AD8 /* SettingsKeyInputView.swift in Sources */, - 88E197D229EDCE6600BF1D19 /* RouteAPI.swift in Sources */, + 8860D76F2B246FC400849FAC /* Text+Extensions.swift in Sources */, + 88E197D229EDCE6600BF1D19 /* SesameRoute.swift in Sources */, + 8860D75E2B237FC600849FAC /* Message+Size.swift in Sources */, + 8860D7552B237F9100849FAC /* TransmissionType.swift in Sources */, + 8860D75C2B237FB900849FAC /* MessageResult+UI.swift in Sources */, + 8860D7632B23803E00849FAC /* ServerChallenge.swift in Sources */, E24065512A819066009C1AD8 /* SettingsTextItemLink.swift in Sources */, 88E197D729EDCFE800BF1D19 /* Date+Extensions.swift in Sources */, - 88E197C829EDCCCE00BF1D19 /* ClientState.swift in Sources */, + 8860D75A2B237FB400849FAC /* SignedMessage.swift in Sources */, E24065602A822ED9009C1AD8 /* HistoryItemDetail.swift in Sources */, + 8860D75F2B237FC900849FAC /* SignedMessage+Size.swift in Sources */, 88E197B229EDC9BC00BF1D19 /* Sesame_WatchApp.swift in Sources */, E24065582A819AE3009C1AD8 /* SettingsKeyItemLink.swift in Sources */, - 88E197C929EDCCE100BF1D19 /* Message.swift in Sources */, + 8860D7602B237FCC00849FAC /* SesameHeader.swift in Sources */, + 8860D7592B237FB200849FAC /* Message+Crypto.swift in Sources */, E24F6C6F2A8974C60040F8C4 /* ConnectionStrategy.swift in Sources */, + 8860D7562B237F9400849FAC /* ActiveRequestType.swift in Sources */, E240654B2A8153C6009C1AD8 /* SettingsListTextItem.swift in Sources */, + 8860D7692B23D04100849FAC /* PendingOperation.swift in Sources */, 88E197C729EDCCBD00BF1D19 /* Client.swift in Sources */, - 88E197D429EDCE7600BF1D19 /* UInt32+Extensions.swift in Sources */, + 88E197D429EDCE7600BF1D19 /* UInt32+Coding.swift in Sources */, E240655B2A822397009C1AD8 /* KeyManagement.swift in Sources */, - E240654D2A8155A3009C1AD8 /* SettingsListToggleItem.swift in Sources */, E24065552A819663009C1AD8 /* SettingsNumberInputView.swift in Sources */, + 8860D7572B237FAD00849FAC /* MessageType.swift in Sources */, E240655E2A822E97009C1AD8 /* HistoryListRow.swift in Sources */, E25317562A8A1ABF005A537D /* HistoryItem.swift in Sources */, + 8860D7582B237FB000849FAC /* Message.swift in Sources */, 88E197D829EDD13B00BF1D19 /* SymmetricKey+Extensions.swift in Sources */, E240655C2A822C8E009C1AD8 /* HistoryManager.swift in Sources */, ); @@ -545,6 +649,7 @@ isa = XCBuildConfiguration; buildSettings = { ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; CLANG_ANALYZER_NONNULL = YES; CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; CLANG_CXX_LANGUAGE_STANDARD = "gnu++17"; @@ -578,6 +683,7 @@ DEBUG_INFORMATION_FORMAT = dwarf; ENABLE_STRICT_OBJC_MSGSEND = YES; ENABLE_TESTABILITY = YES; + ENABLE_USER_SCRIPT_SANDBOXING = YES; GCC_C_LANGUAGE_STANDARD = gnu11; GCC_DYNAMIC_NO_PIC = NO; GCC_NO_COMMON_BLOCKS = YES; @@ -592,7 +698,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 15.2; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; MTL_FAST_MATH = YES; ONLY_ACTIVE_ARCH = YES; @@ -606,6 +712,7 @@ isa = XCBuildConfiguration; buildSettings = { ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; CLANG_ANALYZER_NONNULL = YES; CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; CLANG_CXX_LANGUAGE_STANDARD = "gnu++17"; @@ -639,6 +746,7 @@ DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; ENABLE_NS_ASSERTIONS = NO; ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = YES; GCC_C_LANGUAGE_STANDARD = gnu11; GCC_NO_COMMON_BLOCKS = YES; GCC_WARN_64_TO_32_BIT_CONVERSION = YES; @@ -647,7 +755,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 15.2; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; MTL_ENABLE_DEBUG_INFO = NO; MTL_FAST_MATH = YES; SDKROOT = iphoneos; @@ -676,7 +784,7 @@ INFOPLIST_KEY_UISupportedInterfaceOrientations = UIInterfaceOrientationPortrait; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = UIInterfaceOrientationPortrait; - IPHONEOS_DEPLOYMENT_TARGET = 16.0; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -711,7 +819,7 @@ INFOPLIST_KEY_UISupportedInterfaceOrientations = UIInterfaceOrientationPortrait; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = UIInterfaceOrientationPortrait; - IPHONEOS_DEPLOYMENT_TARGET = 16.0; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -754,7 +862,7 @@ SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = 4; - WATCHOS_DEPLOYMENT_TARGET = 9.4; + WATCHOS_DEPLOYMENT_TARGET = 10.0; }; name = Debug; }; @@ -786,7 +894,7 @@ SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = 4; - WATCHOS_DEPLOYMENT_TARGET = 9.4; + WATCHOS_DEPLOYMENT_TARGET = 10.0; }; name = Release; }; @@ -817,7 +925,7 @@ SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = 4; - WATCHOS_DEPLOYMENT_TARGET = 9.4; + WATCHOS_DEPLOYMENT_TARGET = 10.0; }; name = Debug; }; @@ -848,7 +956,7 @@ SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = 4; - WATCHOS_DEPLOYMENT_TARGET = 9.4; + WATCHOS_DEPLOYMENT_TARGET = 10.0; }; name = Release; }; diff --git a/Sesame.xcodeproj/project.xcworkspace/xcuserdata/imac.xcuserdatad/UserInterfaceState.xcuserstate b/Sesame.xcodeproj/project.xcworkspace/xcuserdata/imac.xcuserdatad/UserInterfaceState.xcuserstate index baf532c..0254b03 100644 Binary files a/Sesame.xcodeproj/project.xcworkspace/xcuserdata/imac.xcuserdatad/UserInterfaceState.xcuserstate and b/Sesame.xcodeproj/project.xcworkspace/xcuserdata/imac.xcuserdatad/UserInterfaceState.xcuserstate differ diff --git a/Sesame.xcodeproj/xcshareddata/xcschemes/Sesame Watch App.xcscheme b/Sesame.xcodeproj/xcshareddata/xcschemes/Sesame Watch App.xcscheme new file mode 100644 index 0000000..671a868 --- /dev/null +++ b/Sesame.xcodeproj/xcshareddata/xcschemes/Sesame Watch App.xcscheme @@ -0,0 +1,77 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Sesame.xcodeproj/xcshareddata/xcschemes/Sesame-WidgetExtension.xcscheme b/Sesame.xcodeproj/xcshareddata/xcschemes/Sesame-WidgetExtension.xcscheme new file mode 100644 index 0000000..8d36d80 --- /dev/null +++ b/Sesame.xcodeproj/xcshareddata/xcschemes/Sesame-WidgetExtension.xcscheme @@ -0,0 +1,111 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Sesame.xcodeproj/xcuserdata/imac.xcuserdatad/xcdebugger/Breakpoints_v2.xcbkptlist b/Sesame.xcodeproj/xcuserdata/imac.xcuserdatad/xcdebugger/Breakpoints_v2.xcbkptlist new file mode 100644 index 0000000..0655738 --- /dev/null +++ b/Sesame.xcodeproj/xcuserdata/imac.xcuserdatad/xcdebugger/Breakpoints_v2.xcbkptlist @@ -0,0 +1,6 @@ + + + diff --git a/Sesame.xcodeproj/xcuserdata/imac.xcuserdatad/xcschemes/xcschememanagement.plist b/Sesame.xcodeproj/xcuserdata/imac.xcuserdatad/xcschemes/xcschememanagement.plist index c304408..75a0224 100644 --- a/Sesame.xcodeproj/xcuserdata/imac.xcuserdatad/xcschemes/xcschememanagement.plist +++ b/Sesame.xcodeproj/xcuserdata/imac.xcuserdatad/xcschemes/xcschememanagement.plist @@ -4,16 +4,39 @@ SchemeUserState + Sesame Watch App.xcscheme_^#shared#^_ + + orderHint + 2 + Sesame-Watch Watch App.xcscheme_^#shared#^_ orderHint - 0 + 2 - Sesame.xcscheme_^#shared#^_ + Sesame-WidgetExtension.xcscheme_^#shared#^_ orderHint 1 + Sesame.xcscheme_^#shared#^_ + + orderHint + 0 + + + SuppressBuildableAutocreation + + 88E197AB29EDC9BC00BF1D19 + + primary + + + E268E0802A85302000185913 + + primary + + diff --git a/Sesame/API Extensions/Message+Crypto.swift b/Sesame/API Extensions/Message+Crypto.swift new file mode 100644 index 0000000..a8e4be5 --- /dev/null +++ b/Sesame/API Extensions/Message+Crypto.swift @@ -0,0 +1,26 @@ +import Foundation +import CryptoKit + +extension Message { + + /** + Calculate an authentication code for the message content. + - Parameter key: The key to use to sign the content. + - Returns: The new message signed with the key. + */ + func authenticate(using key: SymmetricKey) -> SignedMessage { + let mac = HMAC.authenticationCode(for: encoded, using: key) + return .init(mac: Data(mac.map { $0 }), message: self) + } + + /** + Calculate an authentication code for the message content and convert everything to data. + - Parameter key: The key to use to sign the content. + - Returns: The new message signed with the key, serialized to bytes. + */ + func authenticateAndSerialize(using key: SymmetricKey) -> Data { + let encoded = self.encoded + let mac = HMAC.authenticationCode(for: encoded, using: key) + return Data(mac.map { $0 }) + encoded + } +} diff --git a/Sesame/API Extensions/Message.swift b/Sesame/API Extensions/Message.swift new file mode 100644 index 0000000..175d34d --- /dev/null +++ b/Sesame/API Extensions/Message.swift @@ -0,0 +1,123 @@ +import Foundation + +/** + The message content without authentication. + */ +struct Message: Equatable, Hashable { + + /// The type of message being sent. + let messageType: MessageType + + /** + * The random nonce created by the remote + * + * This nonce is a random number created by the remote, different for each unlock request. + * It is set for all message types. + */ + let clientChallenge: UInt32 + + /** + * A random number to sign by the remote + * + * This nonce is set by the server after receiving an initial message. + * It is set for the message types `challenge`, `request`, and `response`. + */ + let serverChallenge: UInt32 + + /** + * The response status for the previous message. + * + * It is set only for messages from the server, e.g. the `challenge` and `response` message types. + * Must be set to `MessageAccepted` for other messages. + */ + let result: MessageResult + + init(messageType: MessageType, clientChallenge: UInt32, serverChallenge: UInt32, result: MessageResult) { + self.messageType = messageType + self.clientChallenge = clientChallenge + self.serverChallenge = serverChallenge + self.result = result + } + + /** + Decode message content from data. + + The data consists of two `UInt32` encoded in little endian format + - Warning: The sequence must contain at least 8 bytes, or the function will crash. + - Parameter data: The sequence containing the bytes. + */ + init(decodeFrom data: Data) throws { + guard data.count == Message.size else { + print("Invalid message size \(data.count)") + throw MessageResult.invalidMessageSizeFromDevice + } + guard let messageType = MessageType(rawValue: data.first!) else { + print("Invalid message type \(data.first!)") + throw MessageResult.invalidMessageTypeFromDevice + } + self.messageType = messageType + self.clientChallenge = UInt32(data: data.dropFirst().prefix(UInt32.byteSize)) + self.serverChallenge = UInt32(data: data.dropFirst(UInt32.byteSize+1).prefix(UInt32.byteSize)) + guard let result = MessageResult(rawValue: data.last!) else { + print("Invalid message result \(data.last!)") + throw MessageResult.unknownMessageResultFromDevice + } + self.result = result + } + + /// The message content encoded to data + var encoded: Data { + messageType.encoded + clientChallenge.encoded + serverChallenge.encoded + result.encoded + } +} + +extension Message: Codable { + + enum CodingKeys: Int, CodingKey { + case messageType = 1 + case clientChallenge = 2 + case serverChallenge = 3 + case result = 4 + } +} + +extension Message { + + init(error: MessageResult, type: MessageType) { + self.init(messageType: type, clientChallenge: 0, serverChallenge: 0, result: error) + } + + static func initial() -> Message { + .init( + messageType: .initial, + clientChallenge: .random(), + serverChallenge: 0, + result: .messageAccepted) + } + + func with(result: MessageResult) -> Message { + .init( + messageType: messageType.responseType, + clientChallenge: clientChallenge, + serverChallenge: serverChallenge, + result: result) + } + + /** + Create the message to respond to this challenge + */ + func requestMessage() -> Message { + .init( + messageType: .request, + clientChallenge: clientChallenge, + serverChallenge: serverChallenge, + result: .messageAccepted) + } +} + +extension Message: CustomStringConvertible { + + var description: String { + "\(messageType)(\(clientChallenge)->\(serverChallenge), \(result))" + } +} diff --git a/Sesame/API Extensions/MessageResult+UI.swift b/Sesame/API Extensions/MessageResult+UI.swift new file mode 100644 index 0000000..9ea4858 --- /dev/null +++ b/Sesame/API Extensions/MessageResult+UI.swift @@ -0,0 +1,118 @@ +import Foundation +import SwiftUI +import SFSafeSymbols + +extension MessageResult { + + var color: Color { + switch self { + + // Initial state when not configured + case .noKeyAvailable: + return Color(red: 50/255, green: 50/255, blue: 50/255) + + // All ready states + case .notChecked, + .messageAccepted, + .deviceAvailable: + return Color(red: 115/255, green: 140/255, blue: 90/255) + + case .unlocked: + return Color(red: 65/255, green: 110/255, blue: 60/255) + + // All implementation errors + case .textReceived, + .unexpectedSocketEvent, + .invalidMessageSizeFromDevice, + .invalidMessageSizeFromRemote, + .invalidMessageTypeFromDevice, + .invalidMessageTypeFromRemote, + .unknownMessageResultFromDevice, + .invalidUrlParameter, + .noOrInvalidBodyDataFromRemote, + .invalidMessageResultFromRemote, + .unexpectedUrlResponseType, + .unexpectedServerResponseCode, + .internalServerError, + .pathOnServerNotFound, + .missingOrInvalidAuthenticationHeaderFromRemote: + return Color(red: 30/255, green: 30/255, blue: 160/255) + + // All security errors + case .invalidSignatureFromRemote, + .invalidServerChallengeFromDevice, + .invalidServerChallengeFromRemote, + .invalidClientChallengeFromDevice, + .invalidClientChallengeFromRemote, + .invalidSignatureFromDevice: + return Color(red: 160/255, green: 30/255, blue: 30/255) + + // Connection errors + case .tooManyRequests, + .deviceTimedOut, + .deviceNotConnected, + .serviceBehindProxyUnavailable: + return Color(red: 150/255, green: 90/255, blue: 90/255) + + // Configuration errors + case .serverUrlInvalid, .invalidServerAuthenticationFromRemote: + return Color(red: 100/255, green: 100/255, blue: 140/255) + } + } + + var symbol: SFSymbol { + switch self { + + // Initial state when not configured + case .noKeyAvailable: + return .questionmarkKeyFilled // .keySlash in 5.0 + + // All ready states + case .notChecked, + .messageAccepted, + .deviceAvailable: + return .checkmark + + case .unlocked: + return .lockOpen + + // All implementation errors + case .textReceived, + .unexpectedSocketEvent, + .invalidMessageSizeFromDevice, + .invalidMessageSizeFromRemote, + .invalidMessageTypeFromDevice, + .invalidMessageTypeFromRemote, + .unknownMessageResultFromDevice, + .invalidUrlParameter, + .noOrInvalidBodyDataFromRemote, + .invalidMessageResultFromRemote, + .unexpectedUrlResponseType, + .unexpectedServerResponseCode, + .internalServerError, + .pathOnServerNotFound, + .missingOrInvalidAuthenticationHeaderFromRemote: + return .questionmarkDiamond + + // All security errors + case .invalidSignatureFromRemote, + .invalidServerChallengeFromDevice, + .invalidServerChallengeFromRemote, + .invalidClientChallengeFromDevice, + .invalidClientChallengeFromRemote, + .invalidSignatureFromDevice: + return .lockTrianglebadgeExclamationmark + + // Connection errors + case .tooManyRequests, + .deviceTimedOut, + .deviceNotConnected, + .serviceBehindProxyUnavailable: + return .antennaRadiowavesLeftAndRightSlash + + // Configuration errors + case .serverUrlInvalid, .invalidServerAuthenticationFromRemote: + return .gearBadgeQuestionmark + } + } +} diff --git a/Sesame/API Extensions/MessageType.swift b/Sesame/API Extensions/MessageType.swift new file mode 100644 index 0000000..4053cbe --- /dev/null +++ b/Sesame/API Extensions/MessageType.swift @@ -0,0 +1,57 @@ +import Foundation + +enum MessageType: UInt8 { + + /// The initial message from remote to device to request a challenge. + case initial = 0 + + /// The second message in an unlock with the challenge from the device to the remote + case challenge = 1 + + /// The third message with the signed challenge from the remote to the device + case request = 2 + + /// The final message with the unlock result from the device to the remote + case response = 3 +} + +extension MessageType { + + var encoded: Data { + Data([rawValue]) + } +} + +extension MessageType: Codable { + +} + +extension MessageType { + + var responseType: MessageType { + switch self { + case .initial: + return .challenge + case .challenge: + return .request + case .request, .response: + return .response + } + } +} + +extension MessageType: CustomStringConvertible { + + var description: String { + switch self { + case .initial: + return "Initial" + case .challenge: + return "Challenge" + case .request: + return "Request" + case .response: + return "Response" + } + } +} diff --git a/Sesame/API Extensions/SignedMessage+Crypto.swift b/Sesame/API Extensions/SignedMessage+Crypto.swift new file mode 100644 index 0000000..58d255b --- /dev/null +++ b/Sesame/API Extensions/SignedMessage+Crypto.swift @@ -0,0 +1,38 @@ +import Foundation +import CryptoKit + +extension SignedMessage { + + /// The message encoded to data + var encoded: Data { + mac + message.encoded + } + + var bytes: [UInt8] { + Array(encoded) + } + + /** + Create a message from received bytes. + - Parameter data: The sequence of bytes + - Note: The sequence must contain at least `Message.length` bytes, or the function will crash. + */ + init(decodeFrom data: Data) throws { + guard data.count == SignedMessage.size else { + print("Invalid signed message size \(data.count)") + throw MessageResult.invalidMessageSizeFromDevice + } + let count = SHA256.byteCount + self.mac = data.prefix(count) + self.message = try Message(decodeFrom: data.dropFirst(count)) + } + + /** + Check if the message contains a valid authentication code + - Parameter key: The key used to sign the message. + - Returns: `true`, if the message is valid. + */ + func isValid(using key: SymmetricKey) -> Bool { + HMAC.isValidAuthenticationCode(mac, authenticating: message.encoded, using: key) + } +} diff --git a/Sesame/API Extensions/SignedMessage.swift b/Sesame/API Extensions/SignedMessage.swift new file mode 100644 index 0000000..01e808d --- /dev/null +++ b/Sesame/API Extensions/SignedMessage.swift @@ -0,0 +1,31 @@ +import Foundation + +/** + An authenticated message to or from the device. + */ +struct SignedMessage: Equatable, Hashable { + + /// The message authentication code for the message (32 bytes) + let mac: Data + + /// The message content + let message: Message + + /** + Create an authenticated message + - Parameter mac: The message authentication code + - Parameter content: The message content + */ + init(mac: Data, message: Message) { + self.mac = mac + self.message = message + } +} + +extension SignedMessage: Codable { + + enum CodingKeys: Int, CodingKey { + case mac = 1 + case message = 2 + } +} diff --git a/Sesame/API/DeviceResponse.swift b/Sesame/API/DeviceResponse.swift deleted file mode 100644 index 0b71b81..0000000 --- a/Sesame/API/DeviceResponse.swift +++ /dev/null @@ -1,90 +0,0 @@ -import Foundation -import NIOCore - -/** - Encapsulates a response from a device. - */ -struct DeviceResponse { - - /// Shorthand property for a timeout event. - static var deviceTimedOut: DeviceResponse { - .init(event: .deviceTimedOut) - } - - /// Shorthand property for a disconnected event. - static var deviceNotConnected: DeviceResponse { - .init(event: .deviceNotConnected) - } - - /// Shorthand property for a connected event. - static var deviceConnected: DeviceResponse { - .init(event: .deviceConnected) - } - - /// Shorthand property for an unexpected socket event. - static var unexpectedSocketEvent: DeviceResponse { - .init(event: .unexpectedSocketEvent) - } - - /// Shorthand property for an invalid message. - static var invalidMessageSize: DeviceResponse { - .init(event: .invalidMessageSize) - } - - /// Shorthand property for missing body data. - static var noBodyData: DeviceResponse { - .init(event: .noBodyData) - } - - /// Shorthand property for a busy connection - static var operationInProgress: DeviceResponse { - .init(event: .operationInProgress) - } - - /// The response to a key from the server - let event: MessageResult - - /// The index of the next key to use - let response: Message? - - /** - Decode a message from a buffer. - - The buffer must contain `Message.length+1` bytes. The first byte denotes the event type, - the remaining bytes contain the message. - - Parameter buffer: The buffer where the message bytes are stored - */ - init?(_ buffer: ByteBuffer) { - guard let byte = buffer.getBytes(at: 0, length: 1) else { - print("No bytes received from device") - return nil - } - guard let event = MessageResult(rawValue: byte[0]) else { - print("Unknown response \(byte[0]) received from device") - return nil - } - self.event = event - guard let data = buffer.getSlice(at: 1, length: Message.length) else { - self.response = nil - return - } - self.response = Message(decodeFrom: data) - } - - /** - Create a response from an event without a message from the device. - - Parameter event: The response from the device. - */ - init(event: MessageResult) { - self.event = event - self.response = nil - } - - /// Get the reponse encoded in bytes. - var encoded: Data { - guard let message = response else { - return event.encoded - } - return event.encoded + message.encoded - } -} diff --git a/Sesame/API/Extensions/Data+Coding.swift b/Sesame/API/Extensions/Data+Coding.swift new file mode 100644 index 0000000..8fbcf45 --- /dev/null +++ b/Sesame/API/Extensions/Data+Coding.swift @@ -0,0 +1,17 @@ +import Foundation + +extension Data { + + func convert(into value: T) -> T { + withUnsafeBytes { + $0.baseAddress!.load(as: T.self) + } + } + + init(from value: T) { + var target = value + self = Swift.withUnsafeBytes(of: &target) { + Data($0) + } + } +} diff --git a/Sesame/API/Data+Extensions.swift b/Sesame/API/Extensions/Data+Hex.swift similarity index 80% rename from Sesame/API/Data+Extensions.swift rename to Sesame/API/Extensions/Data+Hex.swift index 159efeb..f36b39f 100644 --- a/Sesame/API/Data+Extensions.swift +++ b/Sesame/API/Extensions/Data+Hex.swift @@ -40,20 +40,3 @@ extension Data { } } } - -extension Data { - - - func convert(into value: T) -> T { - withUnsafeBytes { - $0.baseAddress!.load(as: T.self) - } - } - - init(from value: T) { - var target = value - self = Swift.withUnsafeBytes(of: &target) { - Data($0) - } - } -} diff --git a/Sesame/API/UInt32+Extensions.swift b/Sesame/API/Extensions/UInt32+Coding.swift similarity index 80% rename from Sesame/API/UInt32+Extensions.swift rename to Sesame/API/Extensions/UInt32+Coding.swift index 90708ef..35e08ab 100644 --- a/Sesame/API/UInt32+Extensions.swift +++ b/Sesame/API/Extensions/UInt32+Coding.swift @@ -15,4 +15,7 @@ extension UInt32 { var encoded: Data { Data(from: CFSwapInt32HostToLittle(self)) } + + /// The size of a `UInt32` when converted to data + static let byteSize = MemoryLayout.size } diff --git a/Sesame/API/Message+Size.swift b/Sesame/API/Message+Size.swift new file mode 100644 index 0000000..bebc957 --- /dev/null +++ b/Sesame/API/Message+Size.swift @@ -0,0 +1,8 @@ +import Foundation + +extension Message { + + /// The byte length of an encoded message content + static let size: Int = 2 + 2 * UInt32.byteSize + +} diff --git a/Sesame/API/Message.swift b/Sesame/API/Message.swift deleted file mode 100644 index 4b690c7..0000000 --- a/Sesame/API/Message.swift +++ /dev/null @@ -1,179 +0,0 @@ -import Foundation -import NIOCore - -#if canImport(CryptoKit) -import CryptoKit -#else -import Crypto -#endif - -/** - An authenticated message to or from the device. - */ -struct Message: Equatable, Hashable { - - /// The message authentication code for the message (32 bytes) - let mac: Data - - /// The message content - let content: Content - - /** - Create an authenticated message - - Parameter mac: The message authentication code - - Parameter content: The message content - */ - init(mac: Data, content: Content) { - self.mac = mac - self.content = content - } -} - -extension Message: Codable { - - enum CodingKeys: Int, CodingKey { - case mac = 1 - case content = 2 - } -} - -extension Message { - - /** - The message content without authentication. - */ - struct Content: Equatable, Hashable { - - /// The time of message creation, in UNIX time (seconds since 1970) - let time: UInt32 - - /// The counter of the message (for freshness) - let id: UInt32 - - let deviceId: UInt8? - - /** - Create new message content. - - Parameter time: The time of message creation, - - Parameter id: The counter of the message - */ - init(time: UInt32, id: UInt32, device: UInt8) { - self.time = time - self.id = id - self.deviceId = device - } - - /** - Decode message content from data. - - The data consists of two `UInt32` encoded in little endian format - - Warning: The sequence must contain at least 8 bytes, or the function will crash. - - Parameter data: The sequence containing the bytes. - */ - init(decodeFrom data: T) where T.Element == UInt8 { - self.time = UInt32(data: Data(data.prefix(MemoryLayout.size))) - self.id = UInt32(data: Data(data.dropLast().suffix(MemoryLayout.size))) - self.deviceId = data.suffix(1).last! - } - - /// The byte length of an encoded message content - static var length: Int { - MemoryLayout.size * 2 + 1 - } - - /// The message content encoded to data - var encoded: Data { - time.encoded + id.encoded + Data([deviceId ?? 0]) - } - } -} - -extension Message.Content: Codable { - - enum CodingKeys: Int, CodingKey { - case time = 1 - case id = 2 - case deviceId = 3 - } -} - -extension Message { - - /// The length of a message in bytes - static var length: Int { - SHA256.byteCount + Content.length - } - - /** - Decode a message from a byte buffer. - The buffer must contain at least `Message.length` bytes, or it will return `nil`. - - Parameter buffer: The buffer containing the bytes. - */ - init?(decodeFrom buffer: ByteBuffer) { - guard let data = buffer.getBytes(at: 0, length: Message.length) else { - return nil - } - self.init(decodeFrom: data) - } - - init?(decodeFrom data: Data, index: inout Int) { - guard index + Message.length <= data.count else { - return nil - } - self.init(decodeFrom: data.advanced(by: index)) - index += Message.length - } - - /// The message encoded to data - var encoded: Data { - mac + content.encoded - } - - var bytes: [UInt8] { - Array(encoded) - } - - /** - Create a message from received bytes. - - Parameter data: The sequence of bytes - - Note: The sequence must contain at least `Message.length` bytes, or the function will crash. - */ - init(decodeFrom data: T) where T.Element == UInt8 { - let count = SHA256.byteCount - self.mac = Data(data.prefix(count)) - self.content = .init(decodeFrom: Array(data.dropFirst(count))) - } - - /** - Check if the message contains a valid authentication code - - Parameter key: The key used to sign the message. - - Returns: `true`, if the message is valid. - */ - func isValid(using key: SymmetricKey) -> Bool { - HMAC.isValidAuthenticationCode(mac, authenticating: content.encoded, using: key) - } -} - -extension Message.Content { - - /** - Calculate an authentication code for the message content. - - Parameter key: The key to use to sign the content. - - Returns: The new message signed with the key. - */ - func authenticate(using key: SymmetricKey) -> Message { - let mac = HMAC.authenticationCode(for: encoded, using: key) - return .init(mac: Data(mac.map { $0 }), content: self) - } - - /** - Calculate an authentication code for the message content and convert everything to data. - - Parameter key: The key to use to sign the content. - - Returns: The new message signed with the key, serialized to bytes. - */ - func authenticateAndSerialize(using key: SymmetricKey) -> Data { - let encoded = self.encoded - let mac = HMAC.authenticationCode(for: encoded, using: key) - return Data(mac.map { $0 }) + encoded - } -} diff --git a/Sesame/API/MessageResult.swift b/Sesame/API/MessageResult.swift index a115ca5..74830eb 100644 --- a/Sesame/API/MessageResult.swift +++ b/Sesame/API/MessageResult.swift @@ -4,93 +4,232 @@ import Foundation A result from sending a key to the device. */ enum MessageResult: UInt8 { + + // MARK: Device status - /// Text content was received, although binary data was expected + /// The message was accepted. + case messageAccepted = 0 + + /// The web socket received text while waiting for binary data. case textReceived = 1 - /// A socket event on the device was unexpected (not binary data) + /// An unexpected socket event occured while performing the exchange. case unexpectedSocketEvent = 2 - /// The size of the payload (i.e. message) was invalid - case invalidMessageSize = 3 + /// The received message size is invalid. + case invalidMessageSizeFromRemote = 3 - /// The transmitted message could not be authenticated using the key - case messageAuthenticationFailed = 4 + /// The message signature was incorrect. + case invalidSignatureFromRemote = 4 - /// The message time was not within the acceptable bounds - case messageTimeMismatch = 5 + /// The server challenge of the message did not match previous messages + case invalidServerChallengeFromRemote = 5 - /// A later key has been used, invalidating this key (to prevent replay attacks after blocked communication) - case messageCounterInvalid = 6 + /// The client challenge of the message did not match previous messages + case invalidClientChallengeFromRemote = 6 - /// The key was accepted by the device, and the door will be opened - case messageAccepted = 7 + /// An unexpected or unsupported message type was received + case invalidMessageTypeFromRemote = 7 + + /// A message is already being processed + case tooManyRequests = 8 - /// The device id is invalid - case messageDeviceInvalid = 8 + /// The received message result was not ``messageAccepted`` + case invalidMessageResultFromRemote = 9 + /// An invalid Url parameter was set sending a message to the device over a local connection + case invalidUrlParameter = 10 + + // MARK: Server status + + /// The body data posting a message was missing or of wrong length + case noOrInvalidBodyDataFromRemote = 21 + + /// The authentication token for the server was invalid + case invalidServerAuthenticationFromRemote = 22 + + /// The request took too long to complete + case deviceTimedOut = 23 + + /// The device is not connected to the server via web socket + case deviceNotConnected = 24 + + /// The device sent a response of invalid size + case invalidMessageSizeFromDevice = 25 + + /// The header with the authentication token was missing or invalid (not a hex string) from a server request. + case missingOrInvalidAuthenticationHeaderFromRemote = 26 + + /// The server produced an internal error (500) + case internalServerError = 27 + + // MARK: Remote status - /// The request did not contain body data with the key - case noBodyData = 10 + /// The initial state without information about the connection + case notChecked = 30 + + /// The url string is not a valid url + case serverUrlInvalid = 31 + + /// The device key or auth token is missing for a request. + case noKeyAvailable = 32 + + /// The Sesame server behind the proxy could not be found (502) + case serviceBehindProxyUnavailable = 33 + + /// The server url could not be found (404) + case pathOnServerNotFound = 34 + + /// The url session request returned an unknown response + case unexpectedUrlResponseType = 35 + + /// The request to the server returned an unhandled HTTP code + case unexpectedServerResponseCode = 36 + + /// A valid server challenge was received + case deviceAvailable = 37 + + case invalidSignatureFromDevice = 38 + + case invalidMessageTypeFromDevice = 39 + + case unknownMessageResultFromDevice = 40 + + /// The device sent a message with an invalid client challenge + case invalidClientChallengeFromDevice = 41 + + /// The device used an invalid server challenge in a response + case invalidServerChallengeFromDevice = 42 + + /// The unlock process was successfully completed + case unlocked = 43 +} - /// The device is not connected - case deviceNotConnected = 12 - - /// The device did not respond within the timeout - case deviceTimedOut = 13 - - /// Another message is being processed by the device - case operationInProgress = 14 - - /// The device is connected - case deviceConnected = 15 - - case invalidUrlParameter = 20 - - case invalidResponseAuthentication = 21 +extension MessageResult: Error { + } extension MessageResult: CustomStringConvertible { var description: String { switch self { + case .messageAccepted: + return "Message accepted" case .textReceived: return "The device received unexpected text" case .unexpectedSocketEvent: return "Unexpected socket event for the device" - case .invalidMessageSize: - return "Invalid message data" - case .messageAuthenticationFailed: + case .invalidMessageSizeFromRemote: + return "Invalid message data from remote" + case .invalidSignatureFromRemote: return "Message authentication failed" - case .messageTimeMismatch: - return "Message time invalid" - case .messageCounterInvalid: - return "Message counter invalid" - case .messageAccepted: - return "Message accepted" - case .messageDeviceInvalid: - return "Invalid device ID" - case .noBodyData: - return "No body data included in the request" - case .deviceNotConnected: - return "Device not connected" - case .deviceTimedOut: - return "The device did not respond" - case .operationInProgress: - return "Another operation is in progress" - case .deviceConnected: - return "The device is connected" + case .invalidServerChallengeFromRemote: + return "Remote used wrong server challenge" + case .invalidClientChallengeFromRemote: + return "Wrong client challenge sent" + case .invalidMessageTypeFromRemote: + return "Message type from remote invalid" + case .tooManyRequests: + return "Device busy" + case .invalidMessageResultFromRemote: + return "Invalid message result" case .invalidUrlParameter: return "The url parameter could not be found" - case .invalidResponseAuthentication: - return "The response could not be authenticated" + + case .noOrInvalidBodyDataFromRemote: + return "Invalid body data in server request" + case .invalidServerAuthenticationFromRemote: + return "Invalid server token" + case .deviceTimedOut: + return "The device did not respond" + case .deviceNotConnected: + return "Device not connected to server" + case .invalidMessageSizeFromDevice: + return "Invalid device message size" + case .missingOrInvalidAuthenticationHeaderFromRemote: + return "Invalid server token format" + case .internalServerError: + return "Internal server error" + + case .notChecked: + return "Not checked" + case .serverUrlInvalid: + return "Invalid server url" + case .noKeyAvailable: + return "No key available" + case .serviceBehindProxyUnavailable: + return "Service behind proxy not found" + case .pathOnServerNotFound: + return "Invalid server path" + case .unexpectedUrlResponseType: + return "Unexpected URL response" + case .unexpectedServerResponseCode: + return "Unexpected server response code" + case .deviceAvailable: + return "Device available" + case .invalidSignatureFromDevice: + return "Invalid device signature" + case .invalidMessageTypeFromDevice: + return "Message type from device invalid" + case .unknownMessageResultFromDevice: + return "Unknown message result" + case .invalidClientChallengeFromDevice: + return "Device used wrong client challenge" + case .invalidServerChallengeFromDevice: + return "Invalid" + case .unlocked: + return "Unlocked" } } } +extension MessageResult: Codable { + +} + extension MessageResult { var encoded: Data { Data([rawValue]) } } + +extension MessageResult { + + init(httpCode: Int) { + switch httpCode { + case 200: self = .messageAccepted + case 204: self = .noOrInvalidBodyDataFromRemote + case 403: self = .invalidServerAuthenticationFromRemote + case 404: self = .pathOnServerNotFound + case 408: self = .deviceTimedOut + case 412: self = .deviceNotConnected + case 413: self = .invalidMessageSizeFromDevice + case 422: self = .missingOrInvalidAuthenticationHeaderFromRemote + case 429: self = .tooManyRequests + case 500: self = .internalServerError + case 501: self = .unexpectedServerResponseCode + case 502: self = .serviceBehindProxyUnavailable + default: self = .unexpectedServerResponseCode + } + } + + var statusCode: Int { + switch self { + case .messageAccepted: return 200 // ok + case .noOrInvalidBodyDataFromRemote: return 204 // noContent + case .invalidServerAuthenticationFromRemote: return 403 // forbidden + case .pathOnServerNotFound: return 404 // notFound + case .deviceTimedOut: return 408 // requestTimeout + case .invalidMessageSizeFromRemote: return 411 // lengthRequired + case .deviceNotConnected: return 412 // preconditionFailed + case .invalidMessageSizeFromDevice: return 413 // payloadTooLarge + case .missingOrInvalidAuthenticationHeaderFromRemote: return 422 // unprocessableEntity + case .tooManyRequests: return 429 // tooManyRequests + case .internalServerError: return 500 // internalServerError + case .unexpectedServerResponseCode: return 501 // notImplemented + case .serviceBehindProxyUnavailable: return 502 // badGateway + default: return 501 // == unexpectedServerResponseCode + } + } +} diff --git a/Sesame/API/ServerMessage.swift b/Sesame/API/ServerMessage.swift deleted file mode 100644 index 783e453..0000000 --- a/Sesame/API/ServerMessage.swift +++ /dev/null @@ -1,26 +0,0 @@ -import Foundation -import NIOCore - -#if canImport(CryptoKit) -import CryptoKit -#else -import Crypto -#endif - -struct ServerMessage { - - static let authTokenSize = SHA256.byteCount - - let authToken: Data - - let message: Message - - init(authToken: Data, message: Message) { - self.authToken = authToken - self.message = message - } - - var encoded: Data { - authToken + message.encoded - } -} diff --git a/Sesame/API/SesameHeader.swift b/Sesame/API/SesameHeader.swift new file mode 100644 index 0000000..5880725 --- /dev/null +++ b/Sesame/API/SesameHeader.swift @@ -0,0 +1,14 @@ +import Foundation +#if canImport(CryptoKit) +import CryptoKit +#else +import Crypto +#endif + +enum SesameHeader { + + static let authenticationHeader = "Authorization" + + static let serverAuthenticationTokenSize = SHA256.byteCount + +} diff --git a/Sesame/API/RouteAPI.swift b/Sesame/API/SesameRoute.swift similarity index 75% rename from Sesame/API/RouteAPI.swift rename to Sesame/API/SesameRoute.swift index 22e90f0..a7477e7 100644 --- a/Sesame/API/RouteAPI.swift +++ b/Sesame/API/SesameRoute.swift @@ -3,10 +3,7 @@ import Foundation /** The active urls on the server, for the device and the remote to connect */ -enum RouteAPI: String { - - /// Check the device status - case getDeviceStatus = "status" +enum SesameRoute: String { /// Send a message to the server, to relay to the device case postMessage = "message" diff --git a/Sesame/API/SignedMessage+Size.swift b/Sesame/API/SignedMessage+Size.swift new file mode 100644 index 0000000..18d6c1c --- /dev/null +++ b/Sesame/API/SignedMessage+Size.swift @@ -0,0 +1,15 @@ +import Foundation + +#if canImport(CryptoKit) +import CryptoKit +#else +import Crypto +#endif + +extension SignedMessage { + + /// The length of a message in bytes + static var size: Int { + SHA256.byteCount + Message.size + } +} diff --git a/Sesame/Common/ActiveRequestType.swift b/Sesame/Common/ActiveRequestType.swift new file mode 100644 index 0000000..2140774 --- /dev/null +++ b/Sesame/Common/ActiveRequestType.swift @@ -0,0 +1,18 @@ +import Foundation + +enum RequestType { + case challenge + case unlock +} + +extension RequestType: CustomStringConvertible { + + var description: String { + switch self { + case .challenge: + return "Challenge" + case .unlock: + return "Unlock" + } + } +} diff --git a/Sesame/Common/Client.swift b/Sesame/Common/Client.swift index be976e3..92e1e9d 100644 --- a/Sesame/Common/Client.swift +++ b/Sesame/Common/Client.swift @@ -3,91 +3,100 @@ import CryptoKit final class Client { - // TODO: Use or delete - private let delegate = NeverCacheDelegate() + private let localRequestRoute = "message" + + private let urlMessageParameter = "m" init() {} - - func deviceStatus(authToken: Data, server: String) async -> ClientState { - await send(path: .getDeviceStatus, server: server, data: authToken).state - } - func sendMessageOverLocalNetwork(_ message: Message, server: String) async -> (state: ClientState, response: Message?) { + func send(_ message: Message, to url: String, through route: TransmissionType, using keys: KeySet) async -> ServerResponse { + let sentTime = Date.now + let signedMessage = message.authenticate(using: keys.remote) + let response: Message + switch route { + case .throughServer: + response = await send(signedMessage, toServerUrl: url, authenticateWith: keys.server, verifyUsing: keys.device) + + case .overLocalWifi: + response = await send(signedMessage, toLocalDeviceUrl: url, verifyUsing: keys.device) + } + let receivedTime = Date.now + // Create best guess for creation of challenge. + let roundTripTime = receivedTime.timeIntervalSince(sentTime) + let serverChallenge = ServerChallenge( + creationDate: sentTime.addingTimeInterval(roundTripTime / 2), + message: response) + + // Validate message content + guard response.result == .messageAccepted else { + print("Failure: \(response)") + return (response, nil) + } + + guard response.clientChallenge == message.clientChallenge else { + print("Invalid client challenge: \(response)") + return (response.with(result: .invalidClientChallengeFromDevice), nil) + } + 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 + "message?m=\(data)") else { - return (.internalError("Invalid server url"), nil) + guard let url = URL(string: server)?.appendingPathComponent("\(localRequestRoute)?\(urlMessageParameter)=\(data)") else { + return message.message.with(result: .serverUrlInvalid) } var request = URLRequest(url: url) request.httpMethod = "POST" - return await requestAndDecode(request) + request.timeoutInterval = 10 + return await perform(request, inResponseTo: message.message, verifyUsing: deviceKey) } - func send(_ message: Message, server: String, authToken: Data) async -> (state: ClientState, response: Message?) { - let serverMessage = ServerMessage(authToken: authToken, message: message) - return await send(path: .postMessage, server: server, data: serverMessage.encoded) - } - - private func send(path: RouteAPI, server: String, data: Data) async -> (state: ClientState, response: Message?) { - guard let url = URL(string: server) else { - return (.internalError("Invalid server url"), nil) + private func send(_ message: SignedMessage, toServerUrl server: String, authenticateWith authToken: Data, verifyUsing deviceKey: SymmetricKey) async -> Message { + guard let url = URL(string: server)?.appendingPathComponent(SesameRoute.postMessage.rawValue) else { + return message.message.with(result: .serverUrlInvalid) } - let fullUrl = url.appendingPathComponent(path.rawValue) - return await send(to: fullUrl, data: data) - } - - private func send(to url: URL, data: Data) async -> (state: ClientState, response: Message?) { + var request = URLRequest(url: url) - request.httpBody = data + request.httpBody = message.encoded request.httpMethod = "POST" request.timeoutInterval = 10 - return await requestAndDecode(request) + request.addValue(authToken.hexEncoded, forHTTPHeaderField: SesameHeader.authenticationHeader) + return await perform(request, inResponseTo: message.message, verifyUsing: deviceKey) } - private func requestAndDecode(_ request: URLRequest) async -> (state: ClientState, response: Message?) { - guard let data = await fulfill(request) else { - return (.deviceNotAvailable(.serverNotReached), nil) + private func perform(_ request: URLRequest, inResponseTo message: Message, verifyUsing deviceKey: SymmetricKey) async -> Message { + let (response, responseData) = await fulfill(request) + guard response == .messageAccepted, let data = responseData else { + return message.with(result: response) } - guard let byte = data.first else { - return (.internalError("Empty response"), nil) + guard data.count == SignedMessage.size else { + print("[WARN] Received message with \(data.count) bytes (\(Array(data)))") + return message.with(result: .invalidMessageSizeFromDevice) } - guard let status = MessageResult(rawValue: byte) else { - return (.internalError("Invalid message response: \(byte)"), nil) + let decodedMessage: SignedMessage + do { + decodedMessage = try SignedMessage(decodeFrom: data) + } catch { + return message.with(result: error as! MessageResult) } - let result = ClientState(keyResult: status) - guard data.count == Message.length + 1 else { - if data.count != 1 { - print("Device response with only \(data.count) bytes") - } - return (result, nil) + guard decodedMessage.isValid(using: deviceKey) else { + return message.with(result: .invalidSignatureFromDevice) } - let messageData = Array(data.advanced(by: 1)) - let message = Message(decodeFrom: messageData) - return (result, message) + return decodedMessage.message } - private func fulfill(_ request: URLRequest) async -> Data? { + private func fulfill(_ request: URLRequest) async -> (response: MessageResult, data: Data?) { do { let (data, response) = try await URLSession.shared.data(for: request) guard let code = (response as? HTTPURLResponse)?.statusCode else { - print("No response from server") - return nil + return (.unexpectedUrlResponseType, nil) } - guard code == 200 else { - print("Invalid server response \(code)") - return nil - } - return data + return (.init(httpCode: code), data) } catch { print("Request failed: \(error)") - return nil + return (.deviceTimedOut, nil) } } } - -class NeverCacheDelegate: NSObject, NSURLConnectionDataDelegate { - - func connection(_ connection: NSURLConnection, willCacheResponse cachedResponse: CachedURLResponse) -> CachedURLResponse? { - return nil - } -} diff --git a/Sesame/Common/ClientState.swift b/Sesame/Common/ClientState.swift deleted file mode 100644 index 746d73c..0000000 --- a/Sesame/Common/ClientState.swift +++ /dev/null @@ -1,371 +0,0 @@ -import Foundation -import SwiftUI -import SFSafeSymbols - -enum ConnectionError { - case serverNotReached - case deviceDisconnected -} - -extension ConnectionError: CustomStringConvertible { - - var description: String { - switch self { - case .serverNotReached: - return "Server unavailable" - case .deviceDisconnected: - return "Device disconnected" - } - } -} - -enum RejectionCause { - case invalidDeviceId - case invalidCounter - case invalidTime - case invalidAuthentication - case timeout - case missingKey -} - -extension RejectionCause: CustomStringConvertible { - - var description: String { - switch self { - case .invalidDeviceId: - return "Invalid device ID" - case .invalidCounter: - return "Invalid counter" - case .invalidTime: - return "Invalid time" - case .invalidAuthentication: - return "Invalid authentication" - case .timeout: - return "Device not responding" - case .missingKey: - return "No key to verify message" - } - } -} - -enum ClientState { - - /// There is no key stored locally on the client. A new key must be generated before use. - case noKeyAvailable - - /// The device status is being requested - case requestingStatus - - /// The remote device is not connected (no socket opened) - case deviceNotAvailable(ConnectionError) - - /// The device is connected and ready to receive a message - case ready - - /// The message is being transmitted and a response is awaited - case waitingForResponse - - /// The transmitted message was rejected (multiple possible reasons) - case messageRejected(RejectionCause) - - case responseRejected(RejectionCause) - - /// The device responded that the opening action was started - case openSesame - - case internalError(String) - - var canSendKey: Bool { - switch self { - case .ready, .openSesame, .messageRejected: - return true - default: - return false - } - } - - init(keyResult: MessageResult) { - switch keyResult { - case .messageAuthenticationFailed: - self = .messageRejected(.invalidAuthentication) - case .messageTimeMismatch: - self = .messageRejected(.invalidTime) - case .messageCounterInvalid: - self = .messageRejected(.invalidCounter) - case .deviceTimedOut: - self = .messageRejected(.timeout) - case .messageAccepted: - self = .openSesame - case .messageDeviceInvalid: - self = .messageRejected(.invalidDeviceId) - case .noBodyData, .invalidMessageSize, .textReceived, .unexpectedSocketEvent, .invalidUrlParameter, .invalidResponseAuthentication: - print("Unexpected internal error: \(keyResult)") - self = .internalError(keyResult.description) - case .deviceNotConnected: - self = .deviceNotAvailable(.deviceDisconnected) - case .operationInProgress: - self = .waitingForResponse - case .deviceConnected: - self = .ready - } - } - - var actionText: String { - switch self { - case .noKeyAvailable: - return "No key" - case .requestingStatus: - return "Checking..." - case .deviceNotAvailable(let connectionError): - switch connectionError { - case .serverNotReached: - return "Server not found" - case .deviceDisconnected: - return "Device disconnected" - } - case .ready: - return "Unlock" - case .waitingForResponse: - return "Unlocking..." - case .messageRejected(let rejectionCause): - switch rejectionCause { - case .invalidDeviceId: - return "Invalid device ID" - case .invalidCounter: - return "Invalid counter" - case .invalidTime: - return "Invalid timestamp" - case .invalidAuthentication: - return "Invalid signature" - case .timeout: - return "Device not responding" - case .missingKey: - return "Device key missing" - } - case .responseRejected(let rejectionCause): - switch rejectionCause { - case .invalidDeviceId: - return "Invalid device id (response)" - case .invalidCounter: - return "Invalid counter (response)" - case .invalidTime: - return "Invalid time (response)" - case .invalidAuthentication: - return "Invalid signature (response)" - case .timeout: - return "Timed out (response)" - case .missingKey: - return "Missing key (response)" - } - case .openSesame: - return "Unlocked" - case .internalError(let string): - return string - } - } - - var requiresDescription: Bool { - switch self { - case .deviceNotAvailable, .messageRejected, .internalError, .responseRejected: - return true - default: - return false - } - } - - var color: Color { - switch self { - case .noKeyAvailable: - return Color(red: 50/255, green: 50/255, blue: 50/255) - case .deviceNotAvailable: - return Color(red: 150/255, green: 90/255, blue: 90/255) - case .messageRejected, .responseRejected: - return Color(red: 160/255, green: 30/255, blue: 30/255) - case .internalError: - return Color(red: 100/255, green: 0/255, blue: 0/255) - case .ready: - return Color(red: 115/255, green: 140/255, blue: 90/255) - case .requestingStatus, .waitingForResponse: - return Color(red: 160/255, green: 170/255, blue: 110/255) - case .openSesame: - return Color(red: 65/255, green: 110/255, blue: 60/255) - } - } - - var allowsAction: Bool { - switch self { - case .noKeyAvailable, .waitingForResponse: - return false - default: - return true - } - } -} - -extension ClientState: Equatable { - -} - -extension ClientState: CustomStringConvertible { - - var description: String { - switch self { - case .noKeyAvailable: - return "No key set." - case .requestingStatus: - return "Checking device status" - case .deviceNotAvailable(let status): - return status.description - case .ready: - return "Ready" - case .waitingForResponse: - return "Unlocking..." - case .messageRejected(let cause): - return cause.description - case .openSesame: - return "Unlocked" - case .internalError(let e): - return "Error: \(e)" - case .responseRejected(let cause): - switch cause { - case .invalidAuthentication: - return "Device message not authenticated" - default: - return cause.description - } - } - } -} - -// MARK: Coding - -extension ClientState { - - var encoded: Data { - Data([code]) - } - - var code: UInt8 { - switch self { - case .noKeyAvailable: - return 1 - case .requestingStatus: - return 2 - case .deviceNotAvailable(let connectionError): - switch connectionError { - case .serverNotReached: - return 3 - case .deviceDisconnected: - return 4 - } - case .ready: - return 5 - case .waitingForResponse: - return 6 - case .messageRejected(let rejectionCause): - switch rejectionCause { - case .invalidDeviceId: - return 19 - case .invalidCounter: - return 7 - case .invalidTime: - return 8 - case .invalidAuthentication: - return 9 - case .timeout: - return 10 - case .missingKey: - return 11 - } - case .responseRejected(let rejectionCause): - switch rejectionCause { - case .invalidCounter: - return 12 - case .invalidTime: - return 13 - case .invalidAuthentication: - return 14 - case .timeout: - return 15 - case .missingKey: - return 16 - case .invalidDeviceId: - return 20 - } - case .openSesame: - return 17 - case .internalError: - return 18 - } - } - - init(code: UInt8) { - switch code { - case 1: - self = .noKeyAvailable - case 2: - self = .requestingStatus - case 3: - self = .deviceNotAvailable(.serverNotReached) - case 4: - self = .deviceNotAvailable(.deviceDisconnected) - case 5: - self = .ready - case 6: - self = .waitingForResponse - case 7: - self = .messageRejected(.invalidCounter) - case 8: - self = .messageRejected(.invalidTime) - case 9: - self = .messageRejected(.invalidAuthentication) - case 10: - self = .messageRejected(.timeout) - case 11: - self = .messageRejected(.missingKey) - case 12: - self = .responseRejected(.invalidCounter) - case 13: - self = .responseRejected(.invalidTime) - case 14: - self = .responseRejected(.invalidAuthentication) - case 15: - self = .responseRejected(.timeout) - case 16: - self = .responseRejected(.missingKey) - case 17: - self = .openSesame - case 18: - self = .internalError("") - case 19: - self = .messageRejected(.invalidDeviceId) - case 20: - self = .responseRejected(.invalidDeviceId) - default: - self = .internalError("Unknown code \(code)") - } - } -} - -extension ClientState { - - @available(iOS 16, *) - var symbol: SFSymbol { - switch self { - case .deviceNotAvailable: - return .wifiExclamationmark - case .internalError: - return .applewatchSlash - case .noKeyAvailable: - return .lockTrianglebadgeExclamationmark - case .openSesame: - return .lockOpen - case .messageRejected: - return .nosign - case .responseRejected: - return .exclamationmarkTriangle - case .requestingStatus, .ready, .waitingForResponse: - return .wifiExclamationmark - } - } -} diff --git a/Sesame/Common/ConnectionStrategy.swift b/Sesame/Common/ConnectionStrategy.swift index 37ff2b1..dee9ce2 100644 --- a/Sesame/Common/ConnectionStrategy.swift +++ b/Sesame/Common/ConnectionStrategy.swift @@ -1,10 +1,27 @@ import Foundation +import CryptoKit -enum ConnectionStrategy: String, CaseIterable, Identifiable { - case local = "Local" - case localFirst = "Local first" - case remote = "Remote" - case remoteFirst = "Remote first" +enum ConnectionStrategy: Int, CaseIterable, Identifiable { + case local = 0 + case remote = 1 + case localFirst = 2 + case remoteFirst = 3 - var id: Self { self } + var id: Int { rawValue } + + var transmissionTypes: [TransmissionType] { + switch self { + case .local: return [.overLocalWifi] + case .localFirst: return [.overLocalWifi, .throughServer] + case .remote: return [.throughServer] + case .remoteFirst: return [.throughServer, .overLocalWifi] + } + } +} + +extension ConnectionStrategy: CustomStringConvertible { + + var description: String { + transmissionTypes.map { $0.displayName }.joined(separator: "+") + } } diff --git a/Sesame/Extensions/Array+Extensions.swift b/Sesame/Common/Extensions/Array+Extensions.swift similarity index 100% rename from Sesame/Extensions/Array+Extensions.swift rename to Sesame/Common/Extensions/Array+Extensions.swift diff --git a/Sesame/Extensions/SymmetricKey+Extensions.swift b/Sesame/Common/Extensions/SymmetricKey+Extensions.swift similarity index 100% rename from Sesame/Extensions/SymmetricKey+Extensions.swift rename to Sesame/Common/Extensions/SymmetricKey+Extensions.swift diff --git a/Sesame/Common/Extensions/Text+Extensions.swift b/Sesame/Common/Extensions/Text+Extensions.swift new file mode 100644 index 0000000..326eeb6 --- /dev/null +++ b/Sesame/Common/Extensions/Text+Extensions.swift @@ -0,0 +1,9 @@ +import Foundation +import SwiftUI + +extension Text { + + init(display: CustomStringConvertible) { + self.init(display.description) + } +} diff --git a/Sesame/Common/Extensions/UInt32+Random.swift b/Sesame/Common/Extensions/UInt32+Random.swift new file mode 100644 index 0000000..b6f1d0a --- /dev/null +++ b/Sesame/Common/Extensions/UInt32+Random.swift @@ -0,0 +1,8 @@ +import Foundation + +extension UInt32 { + + static func random() -> UInt32 { + random(in: UInt32.min...UInt32.max) + } +} diff --git a/Sesame/Common/HistoryManager.swift b/Sesame/Common/HistoryManager.swift index 134594c..91de036 100644 --- a/Sesame/Common/HistoryManager.swift +++ b/Sesame/Common/HistoryManager.swift @@ -1,6 +1,7 @@ import Foundation import CBORCoding +/* class HistoryManagerBase: ObservableObject { @Published @@ -141,3 +142,4 @@ final class HistoryManagerMock: HistoryManagerBase, HistoryManagerProtocol { return true } } +*/ diff --git a/Sesame/Common/KeyManagement.swift b/Sesame/Common/KeyManagement.swift index e235526..09a50a6 100644 --- a/Sesame/Common/KeyManagement.swift +++ b/Sesame/Common/KeyManagement.swift @@ -129,9 +129,8 @@ final class KeyManagement: ObservableObject { @Published private(set) var hasAuthToken = false - var hasAllKeys: Bool { - hasRemoteKey && hasDeviceKey && hasAuthToken - } + @Published + private(set) var hasAllKeys = false init() { self.keyChain = KeyChain(domain: "christophhagen.de") @@ -189,5 +188,6 @@ final class KeyManagement: ObservableObject { self.hasRemoteKey = keyChain.has(.remoteKey) self.hasDeviceKey = keyChain.has(.deviceKey) self.hasAuthToken = keyChain.has(.authToken) + self.hasAllKeys = hasRemoteKey && hasDeviceKey && hasAuthToken } } diff --git a/Sesame/Common/PendingOperation.swift b/Sesame/Common/PendingOperation.swift new file mode 100644 index 0000000..23e6745 --- /dev/null +++ b/Sesame/Common/PendingOperation.swift @@ -0,0 +1,12 @@ +import Foundation + +struct PendingOperation { + + let route: TransmissionType + + let operation: RequestType +} + +extension PendingOperation: Equatable { + +} diff --git a/Sesame/Common/RequestCoordinator.swift b/Sesame/Common/RequestCoordinator.swift new file mode 100644 index 0000000..726b572 --- /dev/null +++ b/Sesame/Common/RequestCoordinator.swift @@ -0,0 +1,229 @@ +import Foundation +import SwiftUI +import SwiftData + +final class RequestCoordinator: ObservableObject { + + @Published + var serverChallenge: ServerChallenge? = nil + + @Published + var state: MessageResult = .noKeyAvailable + + @Published + private var timer: Timer? + + @Published + var pendingRequests: [PendingOperation] = [] + + @Published + var activeRequest: PendingOperation? + + @Published + var keyManager = KeyManagement() + + @AppStorage("server") + var serverPath: String = "https://christophhagen.de/sesame/" + + @AppStorage("localIP") + var localAddress: String = "192.168.178.104/" + + @AppStorage("connectionType") + var connectionType: ConnectionStrategy = .remoteFirst + + let modelContext: ModelContext + + init(modelContext: ModelContext) { + self.modelContext = modelContext + if keyManager.hasAllKeys { + self.state = .notChecked + } + } + + let client = Client() + + var needsNewServerChallenge: Bool { + serverChallenge?.isExpired ?? true + } + + @Published + var isPerformingRequest: Bool = false + + func startUnlock() { + addOperations(.challenge, .unlock) + } + + func startChallenge() { + addOperations(.challenge) + } + + private func addOperations(_ operations: RequestType...) { + #warning("Only perform challenge when doing unlock? Remove code complexity") + // Just add all operations for an unlock + // For every completed operation, the unnecessary ones will be removed without executing them + let operations = connectionType.transmissionTypes.map { route in + operations.map { PendingOperation(route: route, operation: $0) } + }.joined() + + pendingRequests.append(contentsOf: operations) + continueRequests() + } + + private func continueRequests() { + guard activeRequest == nil else { + return + } + guard !pendingRequests.isEmpty else { + self.isPerformingRequest = false + return + } + let activeRequest = pendingRequests.removeFirst() + self.activeRequest = activeRequest + self.isPerformingRequest = true + Task { + await process(request: activeRequest) + } + } + + private func process(request: PendingOperation) async { + let startTime = Date.now + let (success, response, challenge) = await self.start(request) + let endTime = Date.now + let roundTripTime = endTime.timeIntervalSince(startTime) + + if let s = challenge?.message { + print("\(s) took \(Int(roundTripTime * 1000)) ms") + } else { + print("\(request.operation.description) took \(Int(roundTripTime * 1000)) ms") + } + + if request.operation == .unlock, let response { + print("Saving history item") + let item = HistoryItem(message: response, startDate: startTime, route: request.route, finishDate: endTime) + modelContext.insert(item) + } + + DispatchQueue.main.async { + self.filterPendingRequests(after: request, success: success, hasChallenge: challenge != nil) + if let response { + self.state = response.result + } + if let challenge { + self.serverChallenge = challenge + } + self.activeRequest = nil + self.continueRequests() + } + } + + private func filterPendingRequests(after operation: PendingOperation, success: Bool, hasChallenge: Bool) { + if success { + // Filter all unlocks + if operation.operation == .unlock { + // Successful unlock means no need for next challenge or unlocks, so remove all + self.pendingRequests = [] + } else { + // Successful challenge means no need for additional challenges, but keep unlocks + self.pendingRequests = pendingRequests.filter { $0.operation != .challenge } + } + } else { + // Filter all operations with the same route for connection errors + // And with type, depending on error? + } + } + + private func start(_ operation: PendingOperation) async -> OptionalServerResponse { + switch operation.operation { + case .challenge: + if let serverChallenge, !serverChallenge.isExpired { + return (true, serverChallenge.message, serverChallenge) + } + return await performChallenge(route: operation.route) + + case .unlock: + guard let serverChallenge, !serverChallenge.isExpired else { + return (false, nil, nil) + } + return await performUnlock(with: serverChallenge.message, route: operation.route) + } + } + + private func performChallenge(route: TransmissionType) async -> OptionalServerResponse { + let initialMessage = Message.initial() + let (result, challenge) = await send(initialMessage, route: route) + guard let message = challenge?.message else { + return (false, result, nil) + } + // Can't get here without the message being accepted + guard message.messageType == .challenge else { + print("Invalid message type for challenge: \(message)") + return (false, result.with(result: .invalidMessageTypeFromDevice), nil) + } + return (true, result.with(result: .deviceAvailable), challenge) + } + + private func performUnlock(with challenge: Message, route: TransmissionType) async -> OptionalServerResponse { + let request = challenge.requestMessage() + let (unlockState, responseData) = await send(request, route: route) + + guard let response = responseData?.message else { + return (false, unlockState, nil) + } + switch response.messageType { + case .initial, .request: + print("Invalid message type for response: \(response)") + return (false, response.with(result: .invalidMessageTypeFromDevice), nil) + case .challenge: + // New challenge received, challenge was expired + return (true, unlockState, responseData) + case .response: + break + } + + guard response.serverChallenge == request.serverChallenge else { + print("Invalid server challenge for unlock: \(response)") + return (false, response.with(result: .invalidServerChallengeFromDevice), nil) + } + return (true, response.with(result: .unlocked), nil) + } + + private func url(for route: TransmissionType) -> String { + switch route { + case .throughServer: + return serverPath + case .overLocalWifi: + return localAddress + } + } + + private func send(_ message: Message, route: TransmissionType) async -> ServerResponse { + guard let keys = keyManager.getAllKeys() else { + return (message.with(result: .noKeyAvailable), nil) + } + let url = url(for: route) + return await client.send(message, to: url, through: route, using: keys) + } + + func startUpdatingServerChallenge() { + guard timer == nil else { + return + } + DispatchQueue.main.async { + self.timer = Timer.scheduledTimer(withTimeInterval: 3, repeats: true) { [weak self] timer in + guard let self else { + timer.invalidate() + return + } + DispatchQueue.main.async { + self.startChallenge() + } + } + self.timer!.fire() + } + } + + func endUpdatingServerChallenge() { + timer?.invalidate() + timer = nil + } +} diff --git a/Sesame/Common/ServerChallenge.swift b/Sesame/Common/ServerChallenge.swift new file mode 100644 index 0000000..7887e7b --- /dev/null +++ b/Sesame/Common/ServerChallenge.swift @@ -0,0 +1,17 @@ +import Foundation + +struct ServerChallenge { + + private static let challengeExpiryTime: TimeInterval = 25.0 + + let creationDate: Date + + let message: Message + + var isExpired: Bool { + creationDate.addingTimeInterval(ServerChallenge.challengeExpiryTime) < Date.now + } +} + +typealias ServerResponse = (result: Message, challenge: ServerChallenge?) +typealias OptionalServerResponse = (success: Bool, result: Message?, challenge: ServerChallenge?) diff --git a/Sesame/Common/TransmissionType.swift b/Sesame/Common/TransmissionType.swift new file mode 100644 index 0000000..d6969b3 --- /dev/null +++ b/Sesame/Common/TransmissionType.swift @@ -0,0 +1,42 @@ +import Foundation +import SFSafeSymbols + +enum TransmissionType: Int { + case throughServer = 0 + case overLocalWifi = 1 +} + +extension TransmissionType: Codable { + +} + +extension TransmissionType { + + var symbol: SFSymbol { + switch self { + case .throughServer: return .network + case .overLocalWifi: return .wifi + } + } +} + +extension TransmissionType: CaseIterable { + +} + +extension TransmissionType: CustomStringConvertible { + + var description: String { + displayName + } +} + +extension TransmissionType { + + var displayName: String { + switch self { + case .throughServer: return "Mobile" + case .overLocalWifi: return "WiFi" + } + } +} diff --git a/Sesame/ContentView.swift b/Sesame/ContentView.swift index 82495d7..4e997b5 100644 --- a/Sesame/ContentView.swift +++ b/Sesame/ContentView.swift @@ -1,288 +1,98 @@ import SwiftUI +import SwiftData import CryptoKit struct ContentView: View { - @AppStorage("server") - var serverPath: String = "https://christophhagen.de/sesame/" + private let unlockButtonSize: CGFloat = 250 + private let smallButtonSize: CGFloat = 50 + private let buttonBackground: Color = .white.opacity(0.2) + private let buttonColor: Color = .white - @AppStorage("localIP") - var localAddress: String = "192.168.178.104/" - - @AppStorage("counter") - var nextMessageCounter: Int = 0 - - @AppStorage("compensate") - var isCompensatingDaylightTime: Bool = false - - @AppStorage("local") - private var useLocalConnection = false - - @AppStorage("deviceID") - private var deviceID: Int = 0 - @ObservedObject - var keyManager = KeyManagement() - - let history = HistoryManager() + var coordinator: RequestCoordinator - @State - var state: ClientState = .noKeyAvailable - - @State - private var timer: Timer? - - @State - private var hasActiveRequest = false - - @State - private var responseTime: Date? = nil - - @State - private var showSettingsSheet = false - - @State - private var showHistorySheet = false + @State private var showSettingsSheet = false + @State private var showHistorySheet = false + @State private var didShowKeySheetOnce = false - @State - private var didShowKeySheetOnce = false + init(modelContext: ModelContext) { + self.coordinator = .init(modelContext: modelContext) + } - let server = Client() - - var compensationTime: UInt32 { - isCompensatingDaylightTime ? 3600 : 0 - } - - var isPerformingRequests: Bool { - hasActiveRequest || - state == .waitingForResponse - } - - var buttonBackground: Color { - state.allowsAction ? - .white.opacity(0.2) : - .black.opacity(0.2) - } - - let buttonBorderWidth: CGFloat = 3 - - var buttonColor: Color { - state.allowsAction ? .white : .gray - } - - private let buttonWidth: CGFloat = 250 - - private let smallButtonHeight: CGFloat = 50 - - private let smallButtonWidth: CGFloat = 120 - - private let smallButtonBorderWidth: CGFloat = 1 - var body: some View { - GeometryReader { geo in - VStack(spacing: 20) { - HStack { - Button("History", action: { showHistorySheet = true }) - .frame(width: smallButtonWidth, - height: smallButtonHeight) - .background(.white.opacity(0.2)) - .cornerRadius(smallButtonHeight / 2) - .overlay(RoundedRectangle(cornerRadius: smallButtonHeight / 2).stroke(lineWidth: smallButtonBorderWidth).foregroundColor(.white)) - .foregroundColor(.white) - .font(.title2) - .padding() - Spacer() - Button("Settings", action: { showSettingsSheet = true }) - .frame(width: smallButtonWidth, - height: smallButtonHeight) - .background(.white.opacity(0.2)) - .cornerRadius(smallButtonHeight / 2) - .overlay(RoundedRectangle(cornerRadius: smallButtonHeight / 2).stroke(lineWidth: smallButtonBorderWidth).foregroundColor(.white)) - .foregroundColor(.white) - .font(.title2) - .padding() - } + VStack(spacing: 20) { + HStack { Spacer() - if state.requiresDescription { - Text(state.description) - .padding() + Text("Sesame") + .font(.title) + Spacer() + } + Text(coordinator.state.description) + Spacer() + HStack(alignment: .bottom, spacing: 0) { + Button(action: { showHistorySheet = true }) { + Image(systemSymbol: .clockArrowCirclepath) + .foregroundColor(.white) + .frame(width: smallButtonSize, height: smallButtonSize) + .background(.white.opacity(0.2)) + .cornerRadius(smallButtonSize / 2) + .font(.title2) + } - Button(state.actionText, action: mainButtonPressed) - .frame(width: buttonWidth, - height: buttonWidth) + Button("Unlock", action: coordinator.startUnlock) + .frame(width: unlockButtonSize, height: unlockButtonSize) .background(buttonBackground) - .cornerRadius(buttonWidth / 2) - .overlay(RoundedRectangle(cornerRadius: buttonWidth / 2) - .stroke(lineWidth: buttonBorderWidth).foregroundColor(buttonColor)) + .cornerRadius(unlockButtonSize / 2) .foregroundColor(buttonColor) .font(.title) - .disabled(!state.allowsAction) - .padding(.bottom, (geo.size.width-buttonWidth) / 2) - } - .background(state.color) - .onAppear { - if keyManager.hasAllKeys { - state = .requestingStatus + Button(action: { showSettingsSheet = true }) { + Image(systemSymbol: .gearshape) + .foregroundColor(.white) + .frame(width: smallButtonSize, height: smallButtonSize) + .background(.white.opacity(0.2)) + .cornerRadius(smallButtonSize / 2) + .font(.title2) } - startRegularStatusUpdates() } - .onDisappear { - endRegularStatusUpdates() - } - .frame(width: geo.size.width, height: geo.size.height) - .animation(.easeInOut, value: state.color) - .sheet(isPresented: $showSettingsSheet) { - SettingsView( - keyManager: keyManager, - serverAddress: $serverPath, - localAddress: $localAddress, - deviceID: $deviceID, - nextMessageCounter: $nextMessageCounter, - isCompensatingDaylightTime: $isCompensatingDaylightTime, - useLocalConnection: $useLocalConnection) - } - .sheet(isPresented: $showHistorySheet) { - HistoryView(history: history) + + Picker("Connection type", selection: $coordinator.connectionType) { + ForEach(ConnectionStrategy.allCases, id: \.rawValue) { connection in + Text(connection.description).tag(connection) + } } + .pickerStyle(.segmented) + .padding(.horizontal, 30) } + .background(coordinator.state.color) + .onAppear(perform: coordinator.startUpdatingServerChallenge) + .onDisappear(perform: coordinator.endUpdatingServerChallenge) + .animation(.easeInOut, value: coordinator.state.color) + .sheet(isPresented: $showSettingsSheet) { + SettingsView( + keyManager: coordinator.keyManager, + serverAddress: $coordinator.serverPath, + localAddress: $coordinator.localAddress) + } + .sheet(isPresented: $showHistorySheet) { HistoryView() } .preferredColorScheme(.dark) } - func mainButtonPressed() { - guard let key = keyManager.get(.remoteKey), - let token = keyManager.get(.authToken)?.data, - let deviceId = UInt8(exactly: deviceID) else { - return - } - - let count = UInt32(nextMessageCounter) - let sentTime = Date() - // Add time to compensate that the device is using daylight savings time - let content = Message.Content( - time: sentTime.timestamp + compensationTime, - id: count, - device: deviceId) - let message = content.authenticate(using: key) - let historyItem = HistoryItem(sent: message.content, date: sentTime, local: useLocalConnection) - state = .waitingForResponse - print("Sending message \(count)") - Task { - let (newState, responseMessage) = await send(message, authToken: token) - let receivedTime = Date.now - responseTime = receivedTime - state = newState - let finishedItem = historyItem.didReceive(response: newState, date: receivedTime, message: responseMessage?.content) - guard let key = keyManager.get(.deviceKey) else { - save(historyItem: finishedItem.notAuthenticated()) - return - } - guard let responseMessage else { - save(historyItem: finishedItem) - return - } - guard responseMessage.isValid(using: key) else { - save(historyItem: finishedItem.invalidated()) - return - } - - nextMessageCounter = Int(responseMessage.content.id) - save(historyItem: finishedItem) - } - } - private func send(_ message: Message, authToken: Data) async -> (state: ClientState, response: Message?) { - if useLocalConnection { - return await server.sendMessageOverLocalNetwork(message, server: localAddress) - } else { - return await server.send(message, server: serverPath, authToken: authToken) - } - } - - private func save(historyItem: HistoryItem) { - do { - try history.save(item: historyItem) - } catch { - print("Failed to save item: \(error)") - } - } - - - private func startRegularStatusUpdates() { - guard timer == nil else { - return - } - DispatchQueue.main.async { - timer = Timer.scheduledTimer(withTimeInterval: 5, repeats: true, block: checkDeviceStatus) - timer!.fire() - } - } - - private func endRegularStatusUpdates() { - timer?.invalidate() - timer = nil - } - func checkDeviceStatus(_ timer: Timer) { - guard !useLocalConnection else { - return - } - guard let authToken = keyManager.get(.authToken) else { - if !didShowKeySheetOnce { - didShowKeySheetOnce = true - //showSettingsSheet = true - } - return - } - guard !hasActiveRequest else { - return - } - hasActiveRequest = true - Task { - let newState = await server.deviceStatus(authToken: authToken.data, server: serverPath) - hasActiveRequest = false - switch state { - case .noKeyAvailable: - return - case .requestingStatus, .deviceNotAvailable, .ready: - state = newState - case .waitingForResponse: - return - case .messageRejected, .openSesame, .internalError, .responseRejected: - guard let time = responseTime else { - state = newState - return - } - responseTime = nil - // Wait at least 5 seconds after these states have been reached before changing the - // interface to allow sufficient time to see the result - let elapsed = Date.now.timeIntervalSince(time) - guard elapsed < 5 else { - state = newState - return - } - let secondsToWait = Int(elapsed.rounded(.up)) - DispatchQueue.main.asyncAfter(deadline: .now() + .seconds(secondsToWait)) { - state = newState - } - } - } - } } -struct ContentView_Previews: PreviewProvider { - static var previews: some View { - ContentView() - .previewDevice("iPhone 8") - } -} - -extension Date { - - var timestamp: UInt32 { - UInt32(timeIntervalSince1970.rounded()) - } - - init(timestamp: UInt32) { - self.init(timeIntervalSince1970: TimeInterval(timestamp)) +#Preview { + do { + let config = ModelConfiguration(isStoredInMemoryOnly: true) + let container = try ModelContainer(for: HistoryItem.self, configurations: config) + + let item = HistoryItem.mock + container.mainContext.insert(item) + try container.mainContext.save() + return ContentView(modelContext: container.mainContext) + .modelContainer(container) + } catch { + fatalError("Failed to create model container.") } } diff --git a/Sesame/Extensions/Date+Timestamp.swift b/Sesame/Extensions/Date+Timestamp.swift new file mode 100644 index 0000000..188a45f --- /dev/null +++ b/Sesame/Extensions/Date+Timestamp.swift @@ -0,0 +1,12 @@ +import Foundation + +extension Date { + + var timestamp: UInt32 { + UInt32(timeIntervalSince1970.rounded()) + } + + init(timestamp: UInt32) { + self.init(timeIntervalSince1970: TimeInterval(timestamp)) + } +} diff --git a/Sesame/History/HistoryItem.swift b/Sesame/History/HistoryItem.swift index e1e9702..bd00cde 100644 --- a/Sesame/History/HistoryItem.swift +++ b/Sesame/History/HistoryItem.swift @@ -1,114 +1,55 @@ import Foundation +import SwiftData - -struct HistoryItem { +@Model +final class HistoryItem { - /// The sent/received date (local time, not including compensation offset) - let requestDate: Date - - let request: Message.Content + let startDate: Date - let usedLocalConnection: Bool + let message: Message - var response: ClientState + let route: TransmissionType - let responseMessage: Message.Content? + let finishDate: Date - let responseDate: Date - - init(sent message: Message.Content, sentDate: Date, local: Bool, response: ClientState, responseDate: Date, responseMessage: Message.Content?) { - self.requestDate = sentDate - self.request = message - self.responseMessage = responseMessage - self.response = response - self.responseDate = responseDate - self.usedLocalConnection = local + init(message: Message, startDate: Date, route: TransmissionType, finishDate: Date) { + self.startDate = startDate + self.message = message + self.finishDate = finishDate + self.route = route } - - // MARK: Statistics - + var roundTripTime: TimeInterval { - responseDate.timeIntervalSince(requestDate) - } - - var deviceTime: Date? { - guard let timestamp = responseMessage?.time else { - return nil - } - return Date(timestamp: timestamp) - } - - var requestLatency: TimeInterval? { - deviceTime?.timeIntervalSince(requestDate) - } - - var responseLatency: TimeInterval? { - guard let deviceTime = deviceTime else { - return nil - } - return responseDate.timeIntervalSince(deviceTime) - } - - var clockOffset: Int? { - guard let deviceTime = deviceTime else { - return nil - } - let estimatedArrival = requestDate.advanced(by: roundTripTime / 2) - return Int(deviceTime.timeIntervalSince(estimatedArrival)) - } - -} - -extension HistoryItem: Codable { - - enum CodingKeys: Int, CodingKey { - case requestDate = 1 - case request = 2 - case usedLocalConnection = 3 - case response = 4 - case responseMessage = 5 - case responseDate = 6 - } -} - -extension ClientState: Codable { - - init(from decoder: Decoder) throws { - let code = try decoder.singleValueContainer().decode(UInt8.self) - self.init(code: code) + finishDate.timeIntervalSince(startDate) } - func encode(to encoder: Encoder) throws { - var container = encoder.singleValueContainer() - try container.encode(code) + var response: MessageResult { + message.result } } extension HistoryItem: Identifiable { - - var id: UInt32 { - requestDate.timestamp + + var id: Double { + startDate.timeIntervalSince1970 } } extension HistoryItem: Comparable { - + static func < (lhs: HistoryItem, rhs: HistoryItem) -> Bool { - lhs.requestDate < rhs.requestDate + lhs.startDate < rhs.startDate } } extension HistoryItem { - + static var mock: HistoryItem { - let content = Message.Content(time: Date.now.timestamp, id: 123, device: 0) - let content2 = Message.Content(time: (Date.now + 1).timestamp, id: 124, device: 0) + let message = Message(messageType: .request, clientChallenge: 123, serverChallenge: 234, result: .unlocked) return .init( - sent: content, - sentDate: .now, - local: false, - response: .openSesame, - responseDate: .now + 2, - responseMessage: content2) + message: message, + startDate: Date.now.addingTimeInterval(-5), + route: .throughServer, + finishDate: Date.now) } } diff --git a/Sesame/History/HistoryListItem.swift b/Sesame/History/HistoryListItem.swift index 5c65e7e..209e36a 100644 --- a/Sesame/History/HistoryListItem.swift +++ b/Sesame/History/HistoryListItem.swift @@ -1,6 +1,8 @@ import SwiftUI +import SwiftData import SFSafeSymbols + private let df: DateFormatter = { let df = DateFormatter() df.dateStyle = .short @@ -13,60 +15,55 @@ struct HistoryListItem: View { let entry: HistoryItem var entryTime: String { - df.string(from: entry.requestDate) + df.string(from: entry.startDate) } var roundTripText: String { "\(Int(entry.roundTripTime * 1000)) ms" } - - var counterText: String { - let sentCounter = entry.request.id - let startText = "\(sentCounter)" - guard let rCounter = entry.responseMessage?.id else { - return startText - } - let diff = Int(rCounter) - Int(sentCounter) - guard diff != 1 && diff != 0 else { - return startText - } - return startText + " (\(diff))" + + var clientNonceText: String { + "\(entry.message.clientChallenge)" } - - var timeOffsetText: String? { - guard let offset = entry.clockOffset else { - return nil - } - return "\(offset) s" + + var serverNonceText: String { + "\(entry.message.serverChallenge)" } var body: some View { VStack(alignment: .leading) { HStack { + Image(systemSymbol: entry.route.symbol) Text(entry.response.description) .font(.headline) Spacer() Text(entryTime) - }.padding(.bottom, 1) + } HStack { - Image(systemSymbol: entry.usedLocalConnection ? .wifi : .network) - Text(roundTripText) - .font(.subheadline) - Image(systemSymbol: .personalhotspot) - Text(counterText) - .font(.subheadline) - if let timeOffsetText { - Image(systemSymbol: .stopwatch) - Text(timeOffsetText) - .font(.subheadline) - } - }.foregroundColor(.secondary) + Image(systemSymbol: .arrowUpArrowDownCircle) + Text(roundTripText).padding(.trailing) + Image(systemSymbol: .lockIphone) + Text(clientNonceText).padding(.trailing) + Image(systemSymbol: .doorRightHandClosed) + Text(serverNonceText).padding(.trailing) + } + .foregroundColor(.secondary) + .font(.footnote) } } } -struct HistoryListItem_Previews: PreviewProvider { - static var previews: some View { - HistoryListItem(entry: .mock) +#Preview { + do { + let config = ModelConfiguration(isStoredInMemoryOnly: true) + let container = try ModelContainer(for: HistoryItem.self, configurations: config) + + let item = HistoryItem.mock + container.mainContext.insert(item) + try container.mainContext.save() + return HistoryListItem(entry: item) + .modelContainer(container) + } catch { + fatalError("Failed to create model container.") } } diff --git a/Sesame/HistoryView.swift b/Sesame/History/HistoryView.swift similarity index 53% rename from Sesame/HistoryView.swift rename to Sesame/History/HistoryView.swift index e854366..35f1beb 100644 --- a/Sesame/HistoryView.swift +++ b/Sesame/History/HistoryView.swift @@ -1,14 +1,14 @@ import SwiftUI +import SwiftData struct HistoryView: View { - let history: HistoryManagerProtocol - - @State + @Query private var items: [HistoryItem] = [] - @State - private var unlockCount = 0 + private var unlockCount: Int { + items.count { $0.response == .unlocked } + } private var percentage: Double { guard items.count > 0 else { @@ -16,12 +16,19 @@ struct HistoryView: View { } return Double(unlockCount * 100) / Double(items.count) } + + private var requestNumberText: String { + guard items.count != 1 else { + return "1 Request" + } + return "\(items.count) Requests" + } var body: some View { NavigationView { List { HStack { - Text("\(items.count) requests") + Text(requestNumberText) .foregroundColor(.primary) .font(.body) Spacer() @@ -35,26 +42,20 @@ struct HistoryView: View { } .navigationTitle("History") } - .onAppear { - load() - } - } - - private func load() { - Task { - let entries = history.loadEntries() - DispatchQueue.main.async { - items = entries - unlockCount = items.count { - $0.response == .openSesame - } - } - } } } -struct HistoryView_Previews: PreviewProvider { - static var previews: some View { - HistoryView(history: HistoryManagerMock()) +#Preview { + do { + let config = ModelConfiguration(isStoredInMemoryOnly: true) + let container = try ModelContainer(for: HistoryItem.self, configurations: config) + + let item = HistoryItem.mock + container.mainContext.insert(item) + try container.mainContext.save() + return HistoryView() + .modelContainer(container) + } catch { + fatalError("Failed to create model container.") } } diff --git a/Sesame/SesameApp.swift b/Sesame/SesameApp.swift index 2e55633..620540c 100644 --- a/Sesame/SesameApp.swift +++ b/Sesame/SesameApp.swift @@ -1,10 +1,24 @@ import SwiftUI +import SwiftData @main struct SesameApp: App { - var body: some Scene { - WindowGroup { - ContentView() + + @State + var modelContainer: ModelContainer + + init() { + do { + self.modelContainer = try ModelContainer(for: HistoryItem.self) + } catch { + fatalError("Failed to create model container: \(error)") } } + + var body: some Scene { + WindowGroup { + ContentView(modelContext: modelContainer.mainContext) + } + .modelContainer(modelContainer) + } } diff --git a/Sesame/SettingsView.swift b/Sesame/SettingsView.swift index a69500c..96c4220 100644 --- a/Sesame/SettingsView.swift +++ b/Sesame/SettingsView.swift @@ -10,31 +10,6 @@ struct SettingsView: View { @Binding var localAddress: String - @Binding - var deviceID: Int - - @Binding - var nextMessageCounter: Int - - @Binding - var isCompensatingDaylightTime: Bool - - @Binding - var useLocalConnection: Bool - - @State - private var showDeviceIdInput = false - - @State - private var deviceIdText = "" - - @State - private var showCounterInput = false - - @State - private var counterText = "" - - var body: some View { NavigationView { ScrollView { @@ -53,49 +28,11 @@ struct SettingsView: View { .foregroundColor(.secondary) .padding(.leading, 8) }.padding(.vertical, 8) - Toggle(isOn: $useLocalConnection) { - Text("Use direct connection to device") - } - Text("Attempt to communicate directly with the device. This is useful if the server is unavailable. Requires a WiFi connection on the same network as the device.") - .font(.caption) - .foregroundColor(.secondary) - VStack(alignment: .leading) { - Text("Device id") - .bold() - HStack(alignment: .bottom) { - Text("\(deviceID)") - .font(.system(.body, design: .monospaced)) - .foregroundColor(.secondary) - .padding([.trailing, .bottom]) - Button("Edit", action: showAlertToChangeDeviceID) - .padding([.horizontal, .bottom]) - .padding(.top, 4) - } - }.padding(.vertical, 8) - VStack(alignment: .leading) { - Text("Message counter") - .bold() - HStack(alignment: .bottom) { - Text("\(nextMessageCounter)") - .font(.system(.body, design: .monospaced)) - .foregroundColor(.secondary) - .padding([.trailing, .bottom]) - Button("Edit", action: showAlertToChangeCounter) - .padding([.horizontal, .bottom]) - .padding(.top, 4) - } - }.padding(.vertical, 8) ForEach(KeyManagement.KeyType.allCases) { keyType in SingleKeyView( keyManager: keyManager, type: keyType) } - Toggle(isOn: $isCompensatingDaylightTime) { - Text("Compensate daylight savings time") - } - Text("If the remote has daylight savings time wrongly set, then the time validation will fail. Use this option to send messages with adjusted timestamps. Warning: Incorrect use of this option will allow replay attacks.") - .font(.caption) - .foregroundColor(.secondary) }.padding() }.onDisappear { if !localAddress.hasSuffix("/") { @@ -103,54 +40,8 @@ struct SettingsView: View { } } .navigationTitle("Settings") - .alert("Update device ID", isPresented: $showDeviceIdInput, actions: { - TextField("Device ID", text: $deviceIdText) - .keyboardType(.decimalPad) - .font(.system(.body, design: .monospaced)) - .foregroundColor(.black) - Button("Save", action: saveDeviceID) - Button("Cancel", role: .cancel, action: {}) - }, message: { - Text("Enter the device ID") - }) - .alert("Update message counter", isPresented: $showCounterInput, actions: { - TextField("Message counter", text: $counterText) - .keyboardType(.decimalPad) - .font(.system(.body, design: .monospaced)) - .foregroundColor(.black) - Button("Save", action: saveCounter) - Button("Cancel", role: .cancel, action: {}) - }, message: { - Text("Enter the message counter") - }) } } - - private func showAlertToChangeDeviceID() { - deviceIdText = "\(deviceID)" - showDeviceIdInput = true - } - - private func saveDeviceID() { - guard let id = UInt8(deviceIdText) else { - print("Invalid device id '\(deviceIdText)'") - return - } - self.deviceID = Int(id) - } - - private func showAlertToChangeCounter() { - counterText = "\(nextMessageCounter)" - showCounterInput = true - } - - private func saveCounter() { - guard let id = UInt32(counterText) else { - print("Invalid message counter '\(counterText)'") - return - } - self.nextMessageCounter = Int(id) - } } struct SettingsView_Previews: PreviewProvider { @@ -158,10 +49,6 @@ struct SettingsView_Previews: PreviewProvider { SettingsView( keyManager: KeyManagement(), serverAddress: .constant("https://example.com"), - localAddress: .constant("192.168.178.42"), - deviceID: .constant(0), - nextMessageCounter: .constant(12345678), - isCompensatingDaylightTime: .constant(true), - useLocalConnection: .constant(false)) + localAddress: .constant("192.168.178.42")) } }