diff --git a/Sesame.xcodeproj/project.pbxproj b/Sesame.xcodeproj/project.pbxproj index 39061e4..703d792 100644 --- a/Sesame.xcodeproj/project.pbxproj +++ b/Sesame.xcodeproj/project.pbxproj @@ -16,7 +16,12 @@ 884A45C927A43D7900D6E650 /* ClientState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 884A45C827A43D7900D6E650 /* ClientState.swift */; }; 884A45CB27A464C000D6E650 /* SymmetricKey+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 884A45CA27A464C000D6E650 /* SymmetricKey+Extensions.swift */; }; 884A45CD27A465F500D6E650 /* Client.swift in Sources */ = {isa = PBXBuildFile; fileRef = 884A45CC27A465F500D6E650 /* Client.swift */; }; - 884A45CF27A5402D00D6E650 /* Response.swift in Sources */ = {isa = PBXBuildFile; fileRef = 884A45CE27A5402D00D6E650 /* Response.swift */; }; + 884A45CF27A5402D00D6E650 /* MessageResult.swift in Sources */ = {isa = PBXBuildFile; fileRef = 884A45CE27A5402D00D6E650 /* MessageResult.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 */; }; + E24EE77727FF95C00011CFD2 /* NIOCore in Frameworks */ = {isa = PBXBuildFile; productRef = E24EE77627FF95C00011CFD2 /* NIOCore */; }; + E24EE77927FF95E00011CFD2 /* Message.swift in Sources */ = {isa = PBXBuildFile; fileRef = E24EE77827FF95E00011CFD2 /* Message.swift */; }; + E24EE77B280058240011CFD2 /* Message+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = E24EE77A280058240011CFD2 /* Message+Extensions.swift */; }; /* End PBXBuildFile section */ /* Begin PBXFileReference section */ @@ -30,7 +35,11 @@ 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 /* Response.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Response.swift; sourceTree = ""; }; + 884A45CE27A5402D00D6E650 /* MessageResult.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageResult.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 = ""; }; + E24EE77827FF95E00011CFD2 /* Message.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Message.swift; sourceTree = ""; }; + E24EE77A280058240011CFD2 /* Message+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Message+Extensions.swift"; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -38,6 +47,7 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( + E24EE77727FF95C00011CFD2 /* NIOCore in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -64,15 +74,19 @@ isa = PBXGroup; children = ( 884A45B6279F48C100D6E650 /* SesameApp.swift */, + E24EE77827FF95E00011CFD2 /* Message.swift */, + E24EE77A280058240011CFD2 /* Message+Extensions.swift */, 884A45B8279F48C100D6E650 /* ContentView.swift */, 884A45CC27A465F500D6E650 /* Client.swift */, - 884A45CE27A5402D00D6E650 /* Response.swift */, + 884A45CE27A5402D00D6E650 /* MessageResult.swift */, 884A45C827A43D7900D6E650 /* ClientState.swift */, 884A45C627A429EF00D6E650 /* ShareSheet.swift */, 884A45C4279F4BBE00D6E650 /* KeyManagement.swift */, + E24EE77327FF95920011CFD2 /* DeviceResponse.swift */, 884A45CA27A464C000D6E650 /* SymmetricKey+Extensions.swift */, 884A45BA279F48C300D6E650 /* Assets.xcassets */, 884A45BC279F48C300D6E650 /* Preview Content */, + E24EE77127FDCCC00011CFD2 /* Data+Extensions.swift */, ); path = Sesame; sourceTree = ""; @@ -101,6 +115,9 @@ dependencies = ( ); name = Sesame; + packageProductDependencies = ( + E24EE77627FF95C00011CFD2 /* NIOCore */, + ); productName = Sesame; productReference = 884A45B3279F48C100D6E650 /* Sesame.app */; productType = "com.apple.product-type.application"; @@ -129,6 +146,9 @@ Base, ); mainGroup = 884A45AA279F48C100D6E650; + packageReferences = ( + E24EE77527FF95C00011CFD2 /* XCRemoteSwiftPackageReference "swift-nio" */, + ); productRefGroup = 884A45B4279F48C100D6E650 /* Products */; projectDirPath = ""; projectRoot = ""; @@ -155,10 +175,14 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( - 884A45CF27A5402D00D6E650 /* Response.swift in Sources */, + 884A45CF27A5402D00D6E650 /* MessageResult.swift in Sources */, 884A45B9279F48C100D6E650 /* ContentView.swift in Sources */, 884A45CD27A465F500D6E650 /* Client.swift in Sources */, + E24EE77B280058240011CFD2 /* Message+Extensions.swift in Sources */, + E24EE77227FDCCC00011CFD2 /* Data+Extensions.swift in Sources */, + E24EE77427FF95920011CFD2 /* DeviceResponse.swift in Sources */, 884A45CB27A464C000D6E650 /* SymmetricKey+Extensions.swift in Sources */, + E24EE77927FF95E00011CFD2 /* Message.swift in Sources */, 884A45C927A43D7900D6E650 /* ClientState.swift in Sources */, 884A45B7279F48C100D6E650 /* SesameApp.swift in Sources */, 884A45C5279F4BBE00D6E650 /* KeyManagement.swift in Sources */, @@ -367,6 +391,25 @@ defaultConfigurationName = Release; }; /* End XCConfigurationList section */ + +/* Begin XCRemoteSwiftPackageReference section */ + E24EE77527FF95C00011CFD2 /* XCRemoteSwiftPackageReference "swift-nio" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/apple/swift-nio.git"; + requirement = { + kind = upToNextMajorVersion; + minimumVersion = 2.0.0; + }; + }; +/* End XCRemoteSwiftPackageReference section */ + +/* Begin XCSwiftPackageProductDependency section */ + E24EE77627FF95C00011CFD2 /* NIOCore */ = { + isa = XCSwiftPackageProductDependency; + package = E24EE77527FF95C00011CFD2 /* XCRemoteSwiftPackageReference "swift-nio" */; + productName = NIOCore; + }; +/* End XCSwiftPackageProductDependency section */ }; rootObject = 884A45AB279F48C100D6E650 /* Project object */; } diff --git a/Sesame.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Sesame.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved new file mode 100644 index 0000000..aa0d3f4 --- /dev/null +++ b/Sesame.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -0,0 +1,14 @@ +{ + "pins" : [ + { + "identity" : "swift-nio", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-nio.git", + "state" : { + "revision" : "d6e3762e0a5f7ede652559f53623baf11006e17c", + "version" : "2.39.0" + } + } + ], + "version" : 2 +} diff --git a/Sesame.xcodeproj/project.xcworkspace/xcuserdata/ch.xcuserdatad/UserInterfaceState.xcuserstate b/Sesame.xcodeproj/project.xcworkspace/xcuserdata/ch.xcuserdatad/UserInterfaceState.xcuserstate index 9589749..11df39e 100644 Binary files a/Sesame.xcodeproj/project.xcworkspace/xcuserdata/ch.xcuserdatad/UserInterfaceState.xcuserstate and b/Sesame.xcodeproj/project.xcworkspace/xcuserdata/ch.xcuserdatad/UserInterfaceState.xcuserstate differ diff --git a/Sesame.xcodeproj/xcuserdata/ch.xcuserdatad/xcschemes/xcschememanagement.plist b/Sesame.xcodeproj/xcuserdata/ch.xcuserdatad/xcschemes/xcschememanagement.plist index 5ac8256..14b438d 100644 --- a/Sesame.xcodeproj/xcuserdata/ch.xcuserdatad/xcschemes/xcschememanagement.plist +++ b/Sesame.xcodeproj/xcuserdata/ch.xcuserdatad/xcschemes/xcschememanagement.plist @@ -4,10 +4,73 @@ SchemeUserState + BitcoinKit (Playground) 1.xcscheme + + isShown + + orderHint + 5 + + BitcoinKit (Playground) 2.xcscheme + + isShown + + orderHint + 6 + + BitcoinKit (Playground) 3.xcscheme + + isShown + + orderHint + 7 + + BitcoinKit (Playground) 4.xcscheme + + isShown + + orderHint + 8 + + BitcoinKit (Playground) 5.xcscheme + + isShown + + orderHint + 9 + + BitcoinKit (Playground).xcscheme + + isShown + + orderHint + 4 + + Demo (Playground) 1.xcscheme + + isShown + + orderHint + 2 + + Demo (Playground) 2.xcscheme + + isShown + + orderHint + 3 + + Demo (Playground).xcscheme + + isShown + + orderHint + 0 + Sesame.xcscheme_^#shared#^_ orderHint - 0 + 1 diff --git a/Sesame/Client.swift b/Sesame/Client.swift index 7e51653..c1fb623 100644 --- a/Sesame/Client.swift +++ b/Sesame/Client.swift @@ -13,86 +13,88 @@ struct Client { private enum RequestReponse: Error { case requestFailed - case unknownResponse + case unknownResponseData(Data) + case unknownResponseString(String) case success(UInt8) } - func deviceStatus() async throws -> ClientState { + func deviceStatus() async -> ClientState { let url = server.appendingPathComponent("status") let request = URLRequest(url: url, cachePolicy: .reloadIgnoringLocalCacheData) let response = await integerReponse(to: request) switch response { case .requestFailed: - return .statusRequestFailed - case .unknownResponse: - return .unknownDeviceStatus + return .deviceNotAvailable(.serverNotReached) + case .unknownResponseData(let data): + return .internalError("Unknown status (\(data.count) bytes)") + case .unknownResponseString(let string): + return .internalError("Unknown status (\(string.prefix(15)))") case .success(let int): switch int { case 0: - return .deviceDisconnected + return .deviceNotAvailable(.deviceDisconnected) case 1: - return .deviceConnected + return .ready default: - print("Unexpected device status '\(int)'") - return .unknownDeviceStatus + return .internalError("Invalid status: \(int)") } } } - func keyResponse(key: SymmetricKey, id: Int) async throws -> ClientState { - let url = server.appendingPathComponent("key/\(id)") + func send(_ message: Message) async throws -> (state: ClientState, response: Message?) { + let url = server.appendingPathComponent("message") var request = URLRequest(url: url) - request.httpBody = key.data + request.httpBody = message.encoded request.httpMethod = "POST" - let response = await integerReponse(to: request) - switch response { - case .requestFailed: - return .statusRequestFailed - case .unknownResponse: - return .unknownDeviceStatus - case .success(let int): - guard let status = KeyResult(rawValue: int) else { - print("Invalid key response: \(int)") - return .unknownDeviceStatus - } - return ClientState(keyResult: status) + guard let data = await fulfill(request) else { + return (.deviceNotAvailable(.serverNotReached), nil) } + guard let byte = data.first else { + return (.internalError("Empty response"), nil) + } + guard let status = MessageResult(rawValue: byte) else { + return (.internalError("Invalid message response: \(byte)"), nil) + } + let result = ClientState(keyResult: status) + guard data.count == Message.length + 1 else { + return (result, nil) + } + let messageData = Array(data.advanced(by: 1)) + let message = Message(decodeFrom: messageData) + return (result, message) } - private func fulfill(_ request: URLRequest) async -> Result { + private func fulfill(_ request: URLRequest) async -> 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 .failure(.requestFailed) + return nil } guard code == 200 else { print("Invalid server response \(code)") - return .failure(.requestFailed) + return nil } - return .success(data) + return data } catch { print("Request failed: \(error)") - return .failure(.requestFailed) + return nil } } private func integerReponse(to request: URLRequest) async -> RequestReponse { - let response = await fulfill(request) - switch response { - case .failure(let cause): - return cause - case .success(let data): - guard let string = String(data: data, encoding: .utf8) else { - print("Unexpected device status data: \([UInt8](data))") - return .unknownResponse - } - guard let int = UInt8(string) else { - print("Unexpected device status '\(string)'") - return .unknownResponse - } - return .success(int) + guard let data = await fulfill(request) else { + return .requestFailed } + guard let string = String(data: data, encoding: .utf8) else { + print("Unexpected device status data: \([UInt8](data))") + return .unknownResponseData(data) + } + guard let int = UInt8(string) else { + print("Unexpected device status '\(string)'") + return .unknownResponseString(string) + } + return .success(int) } } diff --git a/Sesame/ClientState.swift b/Sesame/ClientState.swift index a25b25d..4510e1c 100644 --- a/Sesame/ClientState.swift +++ b/Sesame/ClientState.swift @@ -1,137 +1,156 @@ import Foundation import SwiftUI -enum ClientState { - - /// The initial state after app launch - case initial - - /// There are no keys stored locally on the client. New keys must be generated before use. - case noKeysAvailable - - /// New keys have been generated and can now be transmitted to the device. - case newKeysGenerated - - /// The device status could not be determined - case statusRequestFailed - - /// The status received from the server could not be decoded - case unknownDeviceStatus - - /// The remote device is not connected (no socket opened) +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 invalidCounter + case invalidTime + case invalidAuthentication + case timeout +} + +extension RejectionCause: CustomStringConvertible { + + var description: String { + switch self { + case .invalidCounter: + return "Invalid counter" + case .invalidTime: + return "Invalid time" + case .invalidAuthentication: + return "Invalid authentication" + case .timeout: + return "Device not responding" + } + } +} + +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 device is connected and ready to receive a key - case deviceConnected - - /// The key is being transmitted and a response is awaited + /// The message is being transmitted and a response is awaited case waitingForResponse - /// The transmitted key was rejected (multiple possible reasons) - case keyRejected - - /// Internal errors with the implementation - case internalError - - /// The configuration of the devices doesn't match - case configurationError + /// The transmitted message was rejected (multiple possible reasons) + case messageRejected(RejectionCause) /// The device responded that the opening action was started case openSesame - - /// All keys have been used - case allKeysUsed + + case internalError(String) var canSendKey: Bool { switch self { - case .deviceConnected, .openSesame, .keyRejected: + case .ready, .openSesame, .messageRejected: return true default: return false } } - init(keyResult: KeyResult) { + init(keyResult: MessageResult) { switch keyResult { - case .textReceived, .unexpectedSocketEvent, .unknownDeviceError: - self = .unknownDeviceStatus - case .invalidPayloadSize, .invalidKeyIndex, .invalidKey: - self = .configurationError - case .keyAlreadyUsed, .keyWasSkipped: - self = .keyRejected - case .keyAccepted: + 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 .noBodyData, .corruptkeyData: - self = .internalError - case .deviceNotConnected, .deviceTimedOut: - self = .deviceDisconnected + case .noBodyData, .invalidMessageData, .textReceived, .unexpectedSocketEvent: + self = .internalError(keyResult.description) + case .deviceNotConnected: + self = .deviceNotAvailable(.deviceDisconnected) + case .operationInProgress: + self = .waitingForResponse } } - - var description: String { - switch self { - case .initial: - return "Checking state..." - case .noKeysAvailable: - return "No keys found" - case .newKeysGenerated: - return "New keys generated" - case .deviceDisconnected: - return "Device not connected" - case .statusRequestFailed: - return "Unable to get device status" - case .unknownDeviceStatus: - return "Unknown device status" - case .deviceConnected: - return "Device connected" - case .waitingForResponse: - return "Waiting for response" - case .internalError: - return "An internal error occured" - case .configurationError: - return "Configuration error" - - case .allKeysUsed: - return "No fresh keys available" - case .keyRejected: - return "The key was rejected" - case .openSesame: - return "Unlocked" - } - } - + var openButtonText: String { switch self { - case .initial, .statusRequestFailed, .unknownDeviceStatus, .deviceDisconnected, .newKeysGenerated, .configurationError, .internalError: - return "Connect" - case .allKeysUsed, .noKeysAvailable: - return "Disabled" - case .deviceConnected, .keyRejected, .openSesame: + case .noKeyAvailable: + return "Create key" + default: return "Unlock" - case .waitingForResponse: - return "Unlocking..." } } var openButtonColor: Color { switch self { - case .initial, .newKeysGenerated, .statusRequestFailed, .waitingForResponse: + case .noKeyAvailable, .requestingStatus: return .yellow - case .noKeysAvailable, .allKeysUsed, .deviceDisconnected, .unknownDeviceStatus, .keyRejected, .configurationError, .internalError: + case .deviceNotAvailable, .messageRejected, .internalError: return .red - case .deviceConnected, .openSesame: + case .ready, .waitingForResponse, .openSesame: return .green } } - var openActionIsEnabled: Bool { + var openButtonIsEnabled: Bool { switch self { - case .allKeysUsed, .noKeysAvailable, .waitingForResponse: + case .requestingStatus, .deviceNotAvailable, .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)" + } + } } diff --git a/Sesame/ContentView.swift b/Sesame/ContentView.swift index 2396a3a..1f97279 100644 --- a/Sesame/ContentView.swift +++ b/Sesame/ContentView.swift @@ -1,154 +1,189 @@ import SwiftUI import CryptoKit -let keyManager = try! KeyManagement() let server = Client(server: URL(string: "https://christophhagen.de/sesame/")!) struct ContentView: View { + + @AppStorage("counter") + var nextMessageCounter: Int = 0 - @State var state: ClientState = .initial - - var canShareKey = false - - @State var showNewKeyWarning = false - - @State var showKeyGenerationFailedWarning = false - - @State var showShareSheetForNewKeys = false - - @State var activeRequestCount = 0 - + @State + var state: ClientState = .noKeyAvailable + + @State + private var timer: Timer? + + @State + private var hasActiveRequest = false + + @State + private var responseTime: Date? = nil + var isPerformingRequests: Bool { - activeRequestCount > 0 + hasActiveRequest || + state == .waitingForResponse } - - var keyText: String { - let totalKeys = keyManager.numberOfKeys - guard totalKeys > 0 else { - return "No keys available" - } - let unusedKeys = keyManager.unusedKeyCount - guard unusedKeys > 0 else { - return "All keys used" - } - return "\(totalKeys - unusedKeys) / \(totalKeys) keys used" + + var buttonBackground: Color { + state.openButtonIsEnabled ? + .white.opacity(0.2) : + .gray.opacity(0.2) } - + + let buttonBorderWidth: CGFloat = 3 + + var buttonColor: Color { + state.openButtonIsEnabled ? .white : .gray + } + private let buttonWidth: CGFloat = 200 - + private let topButtonHeight: CGFloat = 60 - + var body: some View { - VStack(spacing: 20) { - Text(keyText) - Button("Generate new keys", action: { - showNewKeyWarning = true - print("Key regeneration requested") - }) - .padding() - .frame(width: buttonWidth, height: topButtonHeight) - .background(.blue) - .foregroundColor(.white) - .cornerRadius(topButtonHeight / 2) - Button("Share one-time key", action: shareKey) - .padding() - .frame(width: buttonWidth, height: topButtonHeight) - .background(.mint) - .foregroundColor(.white) - .cornerRadius(topButtonHeight / 2) - .disabled(!canShareKey) - - Spacer() - HStack { - if isPerformingRequests { - ProgressView() - .progressViewStyle(CircularProgressViewStyle()) + GeometryReader { geo in + VStack(spacing: 20) { + Spacer() + HStack { + if isPerformingRequests { + ProgressView() + .progressViewStyle(CircularProgressViewStyle()) + } + Text(state.description) + .padding() } - Text(state.description) - .padding() + Button(state.openButtonText, action: mainButtonPressed) + .frame(width: buttonWidth, height: buttonWidth, alignment: .center) + .background(buttonBackground) + .cornerRadius(buttonWidth / 2) + .overlay(RoundedRectangle(cornerRadius: buttonWidth / 2).stroke(lineWidth: buttonBorderWidth).foregroundColor(buttonColor)) + .foregroundColor(buttonColor) + .font(.title) + .disabled(!state.openButtonIsEnabled) + .padding(20) } - Button(state.openButtonText, action: mainButtonPressed) - .frame(width: buttonWidth, height: 80, alignment: .center) - .background(state.openButtonColor) - .cornerRadius(100) - .foregroundColor(.white) - .font(.title2) - .disabled(!state.openActionIsEnabled) + .onAppear { + if KeyManagement.hasKey { + state = .requestingStatus + } + startRegularStatusUpdates() + } + .onDisappear { + endRegularStatusUpdates() + } + .frame(width: geo.size.width, height: geo.size.height) + .background(state.openButtonColor) + .animation(.easeInOut, value: state.openButtonColor) } - .padding(20) - .onAppear { - checkInitialDeviceStatus() - }.alert(isPresented: $showKeyGenerationFailedWarning) { - Alert(title: Text("The keys could not be generated"), - message: Text("All previous keys will be deleted and the lock will be blocked. Are you sure?"), - dismissButton: .default(Text("Okay"))) - }.shareSheet(isPresented: $showShareSheetForNewKeys, items: [keyManager.exportFile]) - .alert(isPresented: $showNewKeyWarning) { - Alert(title: Text("Generate new keys"), - message: Text("All previous keys will be deleted and the lock will be blocked. Are you sure?"), - primaryButton: .destructive(Text("Generate"), action: regenerateKeys), - secondaryButton: .cancel()) - } } func mainButtonPressed() { - print("Main button pressed") - if state.canSendKey { - sendKey() - } else { - checkInitialDeviceStatus() - } - } - - func sendKey() { - guard let key = keyManager.useNextKey() else { - state = .allKeysUsed + guard let key = KeyManagement.key?.remote else { + generateKey() return } - state = .waitingForResponse - activeRequestCount += 1 - print("Sending key \(key.id)") - Task { - let newState = try await server.keyResponse(key: key.key, id: key.id) - activeRequestCount -= 1 - state = newState - } + sendMessage(using: key) } - func checkInitialDeviceStatus() { + func sendMessage(using key: SymmetricKey) { + let count = UInt32(nextMessageCounter) + let now = Date() + let content = Message.Content( + time: now.timestamp, + id: count) + let message = content.authenticate(using: key) + state = .waitingForResponse + print("Sending message \(count)") + Task { + let (newState, message) = try await server.send(message) + responseTime = now + state = newState + if let message = message { + processResponse(message, sendTime: now) + } + } + } + + private func processResponse(_ message: Message, sendTime: Date) { + guard let key = KeyManagement.key?.device else { + return + } + guard message.isValid(using: key) else { + return + } + nextMessageCounter = Int(message.content.id) + print("Next counter is \(message.content.id)") + let now = Date() + let total = now.timeIntervalSince(sendTime) + print("Total time: \(Int(total * 1000)) ms") + let deviceTime = Date(timestamp: message.content.time) + let time1 = deviceTime.timeIntervalSince(sendTime) + let time2 = now.timeIntervalSince(deviceTime) + if time1 < 0 { + print("Device time behind by at least \(Int(-time1 * 1000)) ms behind") + } else if time2 < 0 { + print("Device time behind by at least \(Int(-time2 * 1000)) ms ahead") + } else { + print("Device time synchronized") + } + } + + 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 !hasActiveRequest else { + return + } + hasActiveRequest = true print("Checking device status") Task { - do { - activeRequestCount += 1 - let newState = try await server.deviceStatus() - activeRequestCount -= 1 - print("Device status: \(newState)") - switch newState { - case .noKeysAvailable, .allKeysUsed: + let newState = await server.deviceStatus() + hasActiveRequest = false + switch state { + case .noKeyAvailable: + return + case .requestingStatus, .deviceNotAvailable, .ready: + state = newState + case .waitingForResponse: + return + case .messageRejected, .openSesame, .internalError: + guard let time = responseTime else { + state = newState return - default: + } + responseTime = nil + // Wait at least 5 seconds after these states have been reached before changing the + 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 } - } catch { - print("Failed to get device status: \(error)") - state = .statusRequestFailed } } } - func regenerateKeys() { - print("Regenerate keys") - do { - try keyManager.regenerateKeys() - state = .newKeysGenerated - showKeyGenerationFailedWarning = false - showShareSheetForNewKeys = true - checkInitialDeviceStatus() - } catch { - state = .noKeysAvailable - showKeyGenerationFailedWarning = true - showShareSheetForNewKeys = false - } + func generateKey() { + print("Regenerate key") + KeyManagement.generateNewKeys() + state = .requestingStatus } func shareKey() { @@ -162,3 +197,14 @@ struct ContentView_Previews: PreviewProvider { .previewDevice("iPhone 8") } } + +extension Date { + + var timestamp: UInt32 { + UInt32(timeIntervalSince1970.rounded()) + } + + init(timestamp: UInt32) { + self.init(timeIntervalSince1970: TimeInterval(timestamp)) + } +} diff --git a/Sesame/Data+Extensions.swift b/Sesame/Data+Extensions.swift new file mode 100644 index 0000000..f36b39f --- /dev/null +++ b/Sesame/Data+Extensions.swift @@ -0,0 +1,42 @@ +import Foundation + +extension Data { + + public var hexEncoded: String { + return map { String(format: "%02hhx", $0) }.joined() + } + + // Convert 0 ... 9, a ... f, A ...F to their decimal value, + // return nil for all other input characters + private func decodeNibble(_ u: UInt16) -> UInt8? { + switch(u) { + case 0x30 ... 0x39: + return UInt8(u - 0x30) + case 0x41 ... 0x46: + return UInt8(u - 0x41 + 10) + case 0x61 ... 0x66: + return UInt8(u - 0x61 + 10) + default: + return nil + } + } + + public init?(fromHexEncodedString string: String) { + let utf16 = string.utf16 + self.init(capacity: utf16.count/2) + + var i = utf16.startIndex + guard utf16.count % 2 == 0 else { + return nil + } + while i != utf16.endIndex { + guard let hi = decodeNibble(utf16[i]), + let lo = decodeNibble(utf16[utf16.index(i, offsetBy: 1, limitedBy: utf16.endIndex)!]) else { + return nil + } + var value = hi << 4 + lo + self.append(&value, count: 1) + i = utf16.index(i, offsetBy: 2, limitedBy: utf16.endIndex)! + } + } +} diff --git a/Sesame/DeviceResponse.swift b/Sesame/DeviceResponse.swift new file mode 100644 index 0000000..78cda5b --- /dev/null +++ b/Sesame/DeviceResponse.swift @@ -0,0 +1,65 @@ +import Foundation +import NIOCore + + +struct DeviceResponse { + + static var deviceTimedOut: DeviceResponse { + .init(event: .deviceTimedOut) + } + + static var deviceNotConnected: DeviceResponse { + .init(event: .deviceNotConnected) + } + + static var unexpectedSocketEvent: DeviceResponse { + .init(event: .unexpectedSocketEvent) + } + + static var invalidMessageData: DeviceResponse { + .init(event: .invalidMessageData) + } + + static var noBodyData: DeviceResponse { + .init(event: .noBodyData) + } + + 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? + + 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) + } + + init(event: MessageResult) { + self.event = event + self.response = nil + } + + var encoded: Data { + guard let message = response else { + return Data([event.rawValue]) + } + return Data([event.rawValue]) + message.encoded + } +} diff --git a/Sesame/KeyManagement.swift b/Sesame/KeyManagement.swift index 111b11f..50ef30d 100644 --- a/Sesame/KeyManagement.swift +++ b/Sesame/KeyManagement.swift @@ -3,116 +3,94 @@ import CryptoKit import SwiftUI final class KeyManagement { - - static let securityKeySize: SymmetricKeySize = .bits128 - - enum KeyError: Error { - /// Keys which are already in use can't be exported - case exportAttemptOfUsedKeys - } - - static var documentsDirectory: URL { - let paths = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask) - return paths[0] - } - - private let keyFile = KeyManagement.documentsDirectory.appendingPathComponent("keys") - - let exportFile = KeyManagement.documentsDirectory.appendingPathComponent("export.cpp") - - private var keys: [(key: SymmetricKey, used: Bool)] { - didSet { - do { - try saveKeys() - } catch { - print("Failed to save changed keys: \(error)") - } - } - } - - var numberOfKeys: Int { - keys.count - } - - var hasUsedKeys: Bool { - keys.contains { $0.used } - } - - var hasUnusedKeys: Bool { - unusedKeyCount > 0 - } - - var unusedKeyCount: Int { - guard let id = nextKeyId else { - return 0 - } - return keys.count - id + 1 - } - - var usedKeyCount: Int { - nextKeyId ?? keys.count - } - - var lastKeyId: Int? { - keys.lastIndex { $0.used } - } - - var nextKeyId: Int? { - let index = lastKeyId ?? -1 + 1 - guard index < keys.count else { + + static let tag = "com.ch.sesame.key".data(using: .utf8)! + + private static let label = "sesame" + + private static let keyType = kSecAttrKeyTypeEC + + private static let keyClass = kSecAttrKeyClassSymmetric + + private static let query: [String: Any] = [ + kSecClass as String: kSecClassInternetPassword, + kSecAttrAccount as String: "account", + kSecAttrServer as String: "christophhagen.de", + ]//kSecAttrLabel as String: "sesame"] + + private static func loadKeys() -> Data? { + var query = query + query[kSecReturnData as String] = kCFBooleanTrue + + var item: CFTypeRef? + let status = SecItemCopyMatching(query as CFDictionary, &item) + guard status == errSecSuccess else { + print("Failed to get key: \(status)") return nil } - return index + let key = item as! CFData + print("Key loaded from keychain") + return key as Data } - - init() throws { - guard FileManager.default.fileExists(atPath: keyFile.path) else { - self.keys = [] + + private static func deleteKeys() { + let status = SecItemDelete(query as CFDictionary) + guard status == errSecSuccess || status == errSecItemNotFound else { + print("Failed to remove key: \(status)") return } - let content = try String(contentsOf: keyFile) - self.keys = content.components(separatedBy: "\n") - .enumerated().compactMap { (index, line) -> (SymmetricKey, Bool)? in - let parts = line.components(separatedBy: ":") - guard parts.count == 2 else { - return nil - } - let keyData = Data(base64Encoded: parts[0])! - return (SymmetricKey(data: keyData), parts[1] != "0") - } - print("\(unusedKeyCount) / \(keys.count) keys remaining") + print("Key removed from keychain") } - - func useNextKey() -> (key: SymmetricKey, id: Int)? { - guard let index = nextKeyId else { - return nil + + private static func saveKeys(_ data: Data) { + var query = query + query[kSecValueData as String] = data + let status = SecItemAdd(query as CFDictionary, nil) + guard status == errSecSuccess else { + print("Failed to store key: \(status)") + return } - let key = keys[index].key - keys[index].used = true - return (key, index) + print("Key saved to keychain") } - - func regenerateKeys(count: Int = 100) throws { - self.keys = Self.generateKeys(count: count) - .map { ($0, false) } - let keyString = keys.map { $0.key.codeString }.joined(separator: "\n") - try keyString.write(to: exportFile, atomically: false, encoding: .utf8) - } - - private func saveKeys() throws { - let content = keys.map { key, used -> String in - let keyString = key.withUnsafeBytes { - return Data(Array($0)).base64EncodedString() + + private static var keyData: Data? = loadKeys() { + didSet { + guard let data = keyData else { + deleteKeys() + return } - return keyString + ":" + (used ? "1" : "0") - }.joined(separator: "\n") - try content.write(to: keyFile, atomically: true, encoding: .utf8) - print("Keys saved") - } - - static func generateKeys(count: Int = 100) -> [SymmetricKey] { - (0..(decodeFrom data: T) where T.Element == UInt8 { + let count = SHA256Digest.byteCount + self.mac = Data(data.prefix(count)) + self.content = .init(decodeFrom: Array(data.dropFirst(count))) + } + + func isValid(using key: SymmetricKey) -> Bool { + HMAC.isValidAuthenticationCode(mac, authenticating: content.encoded, using: key) + } +} + +extension Message.Content { + + func authenticate(using key: SymmetricKey) -> Message { + let mac = HMAC.authenticationCode(for: encoded, using: key) + return .init(mac: Data(mac.map { $0 }), content: self) + } + + 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/Message.swift b/Sesame/Message.swift new file mode 100644 index 0000000..415c818 --- /dev/null +++ b/Sesame/Message.swift @@ -0,0 +1,78 @@ +import Foundation +import NIOCore + +struct Message: Equatable, Hashable { + + struct Content: Equatable, Hashable { + + let time: UInt32 + + let id: UInt32 + + init(time: UInt32, id: UInt32) { + self.time = time + self.id = id + } + + init(decodeFrom data: T) where T.Element == UInt8 { + self.time = UInt32(data: data.prefix(4)) + self.id = UInt32(data: data.dropFirst(4)) + } + + static var length: Int { + MemoryLayout.size * 2 + } + + var encoded: Data { + time.encoded + id.encoded + } + + var bytes: [UInt8] { + time.bytes + id.bytes + } + } + + let mac: Data + + let content: Content + + init(mac: Data, content: Content) { + self.mac = mac + self.content = content + } + + init?(decodeFrom buffer: ByteBuffer) { + guard let data = buffer.getBytes(at: 0, length: Message.length) else { + return nil + } + self.init(decodeFrom: data) + } + + var encoded: Data { + mac + content.encoded + } + + var bytes: [UInt8] { + Array(mac) + content.bytes + } +} + + extension UInt32 { + + init(data: T) where T.Element == UInt8 { + self = data + .enumerated() + .map { UInt32($0.element) << ($0.offset * 8) } + .reduce(0, +) + } + + var encoded: Data { + .init(bytes) + } + + var bytes: [UInt8] { + (0..<4).map { + UInt8((self >> ($0*8)) & 0xFF) + } + } +} diff --git a/Sesame/MessageResult.swift b/Sesame/MessageResult.swift new file mode 100644 index 0000000..384332f --- /dev/null +++ b/Sesame/MessageResult.swift @@ -0,0 +1,71 @@ +import Foundation + +/** + A result from sending a key to the device. + */ +enum MessageResult: UInt8 { + + /// Text content was received, although binary data was expected + case textReceived = 1 + + /// A socket event on the device was unexpected (not binary data) + case unexpectedSocketEvent = 2 + + /// The size of the payload (i.e. message) was invalid, or the data could not be read + case invalidMessageData = 3 + + /// The transmitted message could not be authenticated using the key + case messageAuthenticationFailed = 4 + + /// The message time was not within the acceptable bounds + case messageTimeMismatch = 5 + + /// A later key has been used, invalidating this key (to prevent replay attacks after blocked communication) + case messageCounterInvalid = 6 + + /// The key was accepted by the device, and the door will be opened + case messageAccepted = 7 + + + /// The request did not contain body data with the key + case noBodyData = 10 + + /// 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 +} + +extension MessageResult: CustomStringConvertible { + + var description: String { + switch self { + case .textReceived: + return "The device received unexpected text" + case .unexpectedSocketEvent: + return "Unexpected socket event for the device" + case .invalidMessageData: + return "Invalid message data" + case .messageAuthenticationFailed: + return "Message authentication failed" + case .messageTimeMismatch: + return "Message time invalid" + case .messageCounterInvalid: + return "Message counter invalid" + case .messageAccepted: + return "Message accepted" + 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" + } + } +} diff --git a/Sesame/Response.swift b/Sesame/Response.swift deleted file mode 100644 index 44dcbf7..0000000 --- a/Sesame/Response.swift +++ /dev/null @@ -1,80 +0,0 @@ -import Foundation - -/** - A result from sending a key to the device. - */ -enum KeyResult: UInt8 { - - /// Text content was received, although binary data was expected - case textReceived = 1 - - /// A socket event on the device was unexpected (not binary data) - case unexpectedSocketEvent = 2 - - /// The size of the payload (key id + key data, or just key) was invalid - case invalidPayloadSize = 3 - - /// The index of the key was out of bounds - case invalidKeyIndex = 4 - - /// The transmitted key data did not match the expected key - case invalidKey = 5 - - /// The key has been previously used and is no longer valid - case keyAlreadyUsed = 6 - - /// A later key has been used, invalidating this key (to prevent replay attacks after blocked communication) - case keyWasSkipped = 7 - - /// The key was accepted by the device, and the door will be opened - case keyAccepted = 8 - - /// The device produced an unknown error - case unknownDeviceError = 9 - - /// The request did not contain body data with the key - case noBodyData = 10 - - /// The body data could not be read - case corruptkeyData = 11 - - /// The device is not connected - case deviceNotConnected = 12 - - /// The device did not respond within the timeout - case deviceTimedOut = 13 -} - -extension KeyResult: CustomStringConvertible { - - var description: String { - switch self { - case .invalidKeyIndex: - return "Invalid key id (too large)" - case .noBodyData: - return "No body data included in the request" - case .invalidPayloadSize: - return "Invalid key size" - case .corruptkeyData: - return "Key data corrupted" - case .deviceNotConnected: - return "Device not connected" - case .textReceived: - return "The device received unexpected text" - case .unexpectedSocketEvent: - return "Unexpected socket event for the device" - case .invalidKey: - return "The transmitted key was not correct" - case .keyAlreadyUsed: - return "The transmitted key was already used" - case .keyWasSkipped: - return "A newer key was already used" - case .keyAccepted: - return "Key successfully sent" - case .unknownDeviceError: - return "The device experienced an unknown error" - case .deviceTimedOut: - return "The device did not respond" - } - } -}