From a4221c47f7b440b4908a7004b899f7abffdd9823 Mon Sep 17 00:00:00 2001 From: Christoph Hagen Date: Sat, 9 Apr 2022 17:43:33 +0200 Subject: [PATCH] Move to new key system --- Sesame.xcodeproj/project.pbxproj | 51 +++- .../xcshareddata/swiftpm/Package.resolved | 14 + .../UserInterfaceState.xcuserstate | Bin 13063 -> 48308 bytes .../xcschemes/xcschememanagement.plist | 65 +++- Sesame/Client.swift | 88 +++--- Sesame/ClientState.swift | 203 +++++++------ Sesame/ContentView.swift | 284 ++++++++++-------- Sesame/Data+Extensions.swift | 42 +++ Sesame/DeviceResponse.swift | 65 ++++ Sesame/KeyManagement.swift | 180 +++++------ Sesame/Message+Extensions.swift | 40 +++ Sesame/Message.swift | 78 +++++ Sesame/MessageResult.swift | 71 +++++ Sesame/Response.swift | 80 ----- 14 files changed, 821 insertions(+), 440 deletions(-) create mode 100644 Sesame.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved create mode 100644 Sesame/Data+Extensions.swift create mode 100644 Sesame/DeviceResponse.swift create mode 100644 Sesame/Message+Extensions.swift create mode 100644 Sesame/Message.swift create mode 100644 Sesame/MessageResult.swift delete mode 100644 Sesame/Response.swift 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 95897497e1f9da5506a4b1339dfd770a660ba0af..11df39e65f26ee30ba01ecceec6f25ff55433370 100644 GIT binary patch literal 48308 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 delta 7380 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 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" - } - } -}