From 941aebd9ca410b53fef906199c9dc2ea9984a259 Mon Sep 17 00:00:00 2001 From: Christoph Hagen Date: Tue, 12 Dec 2023 17:33:42 +0100 Subject: [PATCH] Challenge-response, SwiftData, new UI --- Sesame-Watch Watch App/ContentView.swift | 210 ++-------- .../HistoryItemDetail.swift | 58 ++- Sesame-Watch Watch App/HistoryListRow.swift | 2 +- Sesame-Watch Watch App/HistoryView.swift | 41 +- Sesame-Watch Watch App/Sesame_WatchApp.swift | 26 +- .../Settings/SettingsListToggleItem.swift | 28 -- Sesame-Watch Watch App/SettingsView.swift | 31 +- Sesame.xcodeproj/project.pbxproj | 214 +++++++--- .../UserInterfaceState.xcuserstate | Bin 64302 -> 138122 bytes .../xcschemes/Sesame Watch App.xcscheme | 77 ++++ .../xcschemes/Sesame-WidgetExtension.xcscheme | 111 ++++++ .../xcdebugger/Breakpoints_v2.xcbkptlist | 6 + .../xcschemes/xcschememanagement.plist | 27 +- Sesame/API Extensions/Message+Crypto.swift | 26 ++ Sesame/API Extensions/Message.swift | 123 ++++++ Sesame/API Extensions/MessageResult+UI.swift | 118 ++++++ Sesame/API Extensions/MessageType.swift | 57 +++ .../API Extensions/SignedMessage+Crypto.swift | 38 ++ Sesame/API Extensions/SignedMessage.swift | 31 ++ Sesame/API/DeviceResponse.swift | 90 ----- Sesame/API/Extensions/Data+Coding.swift | 17 + .../Data+Hex.swift} | 17 - .../UInt32+Coding.swift} | 3 + Sesame/API/Message+Size.swift | 8 + Sesame/API/Message.swift | 179 --------- Sesame/API/MessageResult.swift | 247 +++++++++--- Sesame/API/ServerMessage.swift | 26 -- Sesame/API/SesameHeader.swift | 14 + .../API/{RouteAPI.swift => SesameRoute.swift} | 5 +- Sesame/API/SignedMessage+Size.swift | 15 + Sesame/Common/ActiveRequestType.swift | 18 + Sesame/Common/Client.swift | 123 +++--- Sesame/Common/ClientState.swift | 371 ------------------ Sesame/Common/ConnectionStrategy.swift | 29 +- .../Extensions/Array+Extensions.swift | 0 .../Extensions/SymmetricKey+Extensions.swift | 0 .../Common/Extensions/Text+Extensions.swift | 9 + Sesame/Common/Extensions/UInt32+Random.swift | 8 + Sesame/Common/HistoryManager.swift | 2 + Sesame/Common/KeyManagement.swift | 6 +- Sesame/Common/PendingOperation.swift | 12 + Sesame/Common/RequestCoordinator.swift | 229 +++++++++++ Sesame/Common/ServerChallenge.swift | 17 + Sesame/Common/TransmissionType.swift | 42 ++ Sesame/ContentView.swift | 328 ++++------------ Sesame/Extensions/Date+Timestamp.swift | 12 + Sesame/History/HistoryItem.swift | 113 ++---- Sesame/History/HistoryListItem.swift | 67 ++-- Sesame/{ => History}/HistoryView.swift | 49 +-- Sesame/SesameApp.swift | 20 +- Sesame/SettingsView.swift | 115 +----- 51 files changed, 1741 insertions(+), 1674 deletions(-) delete mode 100644 Sesame-Watch Watch App/Settings/SettingsListToggleItem.swift create mode 100644 Sesame.xcodeproj/xcshareddata/xcschemes/Sesame Watch App.xcscheme create mode 100644 Sesame.xcodeproj/xcshareddata/xcschemes/Sesame-WidgetExtension.xcscheme create mode 100644 Sesame.xcodeproj/xcuserdata/imac.xcuserdatad/xcdebugger/Breakpoints_v2.xcbkptlist create mode 100644 Sesame/API Extensions/Message+Crypto.swift create mode 100644 Sesame/API Extensions/Message.swift create mode 100644 Sesame/API Extensions/MessageResult+UI.swift create mode 100644 Sesame/API Extensions/MessageType.swift create mode 100644 Sesame/API Extensions/SignedMessage+Crypto.swift create mode 100644 Sesame/API Extensions/SignedMessage.swift delete mode 100644 Sesame/API/DeviceResponse.swift create mode 100644 Sesame/API/Extensions/Data+Coding.swift rename Sesame/API/{Data+Extensions.swift => Extensions/Data+Hex.swift} (80%) rename Sesame/API/{UInt32+Extensions.swift => Extensions/UInt32+Coding.swift} (80%) create mode 100644 Sesame/API/Message+Size.swift delete mode 100644 Sesame/API/Message.swift delete mode 100644 Sesame/API/ServerMessage.swift create mode 100644 Sesame/API/SesameHeader.swift rename Sesame/API/{RouteAPI.swift => SesameRoute.swift} (75%) create mode 100644 Sesame/API/SignedMessage+Size.swift create mode 100644 Sesame/Common/ActiveRequestType.swift delete mode 100644 Sesame/Common/ClientState.swift rename Sesame/{ => Common}/Extensions/Array+Extensions.swift (100%) rename Sesame/{ => Common}/Extensions/SymmetricKey+Extensions.swift (100%) create mode 100644 Sesame/Common/Extensions/Text+Extensions.swift create mode 100644 Sesame/Common/Extensions/UInt32+Random.swift create mode 100644 Sesame/Common/PendingOperation.swift create mode 100644 Sesame/Common/RequestCoordinator.swift create mode 100644 Sesame/Common/ServerChallenge.swift create mode 100644 Sesame/Common/TransmissionType.swift create mode 100644 Sesame/Extensions/Date+Timestamp.swift rename Sesame/{ => History}/HistoryView.swift (53%) 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 baf532c5d41db331c51ff2bcd268693cda730aa9..0254b03b9691be4b5066cab20e8d85ab37be923a 100644 GIT binary patch literal 138122 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

?b(R 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 literal 64302 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 cE~s2kyP)}V(VnE_&s6Ea-)k5DPA + + + + + + + + + + + + + + + + + + + + + + + + + + + + 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")) } }