diff --git a/Sesame.xcodeproj/project.pbxproj b/Sesame.xcodeproj/project.pbxproj index 3081a47..b3132e3 100644 --- a/Sesame.xcodeproj/project.pbxproj +++ b/Sesame.xcodeproj/project.pbxproj @@ -20,8 +20,11 @@ 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 */; }; + E28DED2D281E840B00259690 /* KeyView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E28DED2C281E840B00259690 /* KeyView.swift */; }; + E28DED2F281E8A0500259690 /* SingleKeyView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E28DED2E281E8A0500259690 /* SingleKeyView.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 */; }; /* End PBXBuildFile section */ /* Begin PBXFileReference section */ @@ -38,8 +41,11 @@ 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 = ""; }; + E28DED2C281E840B00259690 /* KeyView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeyView.swift; sourceTree = ""; }; + E28DED2E281E8A0500259690 /* SingleKeyView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SingleKeyView.swift; 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 = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -76,11 +82,12 @@ E2C5C1D92806FE4A00769EF6 /* API */, 884A45B6279F48C100D6E650 /* SesameApp.swift */, 884A45B8279F48C100D6E650 /* ContentView.swift */, + E28DED2C281E840B00259690 /* KeyView.swift */, + E28DED2E281E8A0500259690 /* SingleKeyView.swift */, 884A45CC27A465F500D6E650 /* Client.swift */, 884A45C827A43D7900D6E650 /* ClientState.swift */, 884A45C4279F4BBE00D6E650 /* KeyManagement.swift */, 884A45CA27A464C000D6E650 /* SymmetricKey+Extensions.swift */, - E24EE77127FDCCC00011CFD2 /* Data+Extensions.swift */, 884A45BA279F48C300D6E650 /* Assets.xcassets */, 884A45BC279F48C300D6E650 /* Preview Content */, ); @@ -98,10 +105,12 @@ E2C5C1D92806FE4A00769EF6 /* API */ = { isa = PBXGroup; children = ( + E24EE77127FDCCC00011CFD2 /* Data+Extensions.swift */, E24EE77327FF95920011CFD2 /* DeviceResponse.swift */, E24EE77827FF95E00011CFD2 /* Message.swift */, 884A45CE27A5402D00D6E650 /* MessageResult.swift */, E2C5C1DA2806FE8900769EF6 /* RouteAPI.swift */, + E2C5C1F7281E769F00769EF6 /* ServerMessage.swift */, E2C5C1DC281B3AC400769EF6 /* UInt32+Extensions.swift */, ); path = API; @@ -185,6 +194,7 @@ files = ( 884A45CF27A5402D00D6E650 /* MessageResult.swift in Sources */, 884A45B9279F48C100D6E650 /* ContentView.swift in Sources */, + E28DED2F281E8A0500259690 /* SingleKeyView.swift in Sources */, E2C5C1DB2806FE8900769EF6 /* RouteAPI.swift in Sources */, E2C5C1DD281B3AC400769EF6 /* UInt32+Extensions.swift in Sources */, 884A45CD27A465F500D6E650 /* Client.swift in Sources */, @@ -193,8 +203,10 @@ 884A45CB27A464C000D6E650 /* SymmetricKey+Extensions.swift in Sources */, E24EE77927FF95E00011CFD2 /* Message.swift in Sources */, 884A45C927A43D7900D6E650 /* ClientState.swift in Sources */, + E28DED2D281E840B00259690 /* KeyView.swift in Sources */, 884A45B7279F48C100D6E650 /* SesameApp.swift in Sources */, 884A45C5279F4BBE00D6E650 /* KeyManagement.swift in Sources */, + E2C5C1F8281E769F00769EF6 /* ServerMessage.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/Sesame.xcodeproj/project.xcworkspace/xcuserdata/ch.xcuserdatad/UserInterfaceState.xcuserstate b/Sesame.xcodeproj/project.xcworkspace/xcuserdata/ch.xcuserdatad/UserInterfaceState.xcuserstate index dff6be4..a4bb03d 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/Data+Extensions.swift b/Sesame/API/Data+Extensions.swift similarity index 100% rename from Sesame/Data+Extensions.swift rename to Sesame/API/Data+Extensions.swift diff --git a/Sesame/API/DeviceResponse.swift b/Sesame/API/DeviceResponse.swift index 10e3749..b5157bc 100644 --- a/Sesame/API/DeviceResponse.swift +++ b/Sesame/API/DeviceResponse.swift @@ -16,6 +16,11 @@ struct 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) diff --git a/Sesame/API/Message.swift b/Sesame/API/Message.swift index 4bed14a..b29024c 100644 --- a/Sesame/API/Message.swift +++ b/Sesame/API/Message.swift @@ -101,6 +101,10 @@ extension Message { mac + content.encoded } + var bytes: [UInt8] { + Array(encoded) + } + /** Create a message from received bytes. - Parameter data: The sequence of bytes diff --git a/Sesame/API/MessageResult.swift b/Sesame/API/MessageResult.swift index 384332f..ecf41af 100644 --- a/Sesame/API/MessageResult.swift +++ b/Sesame/API/MessageResult.swift @@ -38,6 +38,9 @@ enum MessageResult: UInt8 { /// Another message is being processed by the device case operationInProgress = 14 + + /// The device is connected + case deviceConnected = 15 } extension MessageResult: CustomStringConvertible { @@ -66,6 +69,8 @@ extension MessageResult: CustomStringConvertible { return "The device did not respond" case .operationInProgress: return "Another operation is in progress" + case .deviceConnected: + return "The device is connected" } } } diff --git a/Sesame/API/ServerMessage.swift b/Sesame/API/ServerMessage.swift new file mode 100644 index 0000000..1e4dfb6 --- /dev/null +++ b/Sesame/API/ServerMessage.swift @@ -0,0 +1,51 @@ +import Foundation +import NIOCore + +#if canImport(CryptoKit) +import CryptoKit +#else +import Crypto +#endif + +struct ServerMessage { + + static let authTokenSize = SHA256.byteCount + + static let length = authTokenSize + Message.length + + let authToken: Data + + let message: Message + + init(authToken: Data, message: Message) { + self.authToken = authToken + self.message = message + } + + /** + Decode a message from a byte buffer. + The buffer must contain at least `ServerMessage.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: ServerMessage.length) else { + return nil + } + self.authToken = Data(data.prefix(ServerMessage.authTokenSize)) + self.message = Message(decodeFrom: Data(data.dropFirst(ServerMessage.authTokenSize))) + } + + var encoded: Data { + authToken + message.encoded + } + + static func token(from buffer: ByteBuffer) -> Data? { + guard buffer.readableBytes == authTokenSize else { + return nil + } + guard let bytes = buffer.getBytes(at: 0, length: authTokenSize) else { + return nil + } + return Data(bytes) + } +} diff --git a/Sesame/Client.swift b/Sesame/Client.swift index 74106c9..2e706f0 100644 --- a/Sesame/Client.swift +++ b/Sesame/Client.swift @@ -10,41 +10,20 @@ struct Client { init(server: URL) { self.server = server } + + func deviceStatus(authToken: Data) async -> ClientState { + await send(path: .getDeviceStatus, data: authToken).state + } - private enum RequestReponse: Error { - case requestFailed - case unknownResponseData(Data) - case unknownResponseString(String) - case success(UInt8) + func send(_ message: Message, authToken: Data) async -> (state: ClientState, response: Message?) { + let serverMessage = ServerMessage(authToken: authToken, message: message) + return await send(path: .postMessage, data: serverMessage.encoded) } - func deviceStatus() async -> ClientState { - let url = server.appendingPathComponent(RouteAPI.getDeviceStatus.rawValue) - let request = URLRequest(url: url, cachePolicy: .reloadIgnoringLocalCacheData) - let response = await integerReponse(to: request) - switch response { - case .requestFailed: - 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 .deviceNotAvailable(.deviceDisconnected) - case 1: - return .ready - default: - return .internalError("Invalid status: \(int)") - } - } - } - - func send(_ message: Message) async throws -> (state: ClientState, response: Message?) { - let url = server.appendingPathComponent(RouteAPI.postMessage.rawValue) + private func send(path: RouteAPI, data: Data) async -> (state: ClientState, response: Message?) { + let url = server.appendingPathComponent(path.rawValue) var request = URLRequest(url: url) - request.httpBody = message.encoded + request.httpBody = data request.httpMethod = "POST" guard let data = await fulfill(request) else { return (.deviceNotAvailable(.serverNotReached), nil) @@ -81,22 +60,6 @@ struct Client { return nil } } - - private func integerReponse(to request: URLRequest) async -> RequestReponse { - 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) - } - } class NeverCacheDelegate: NSObject, NSURLConnectionDataDelegate { diff --git a/Sesame/ClientState.swift b/Sesame/ClientState.swift index 19dbca3..356be3f 100644 --- a/Sesame/ClientState.swift +++ b/Sesame/ClientState.swift @@ -93,16 +93,13 @@ enum ClientState { self = .deviceNotAvailable(.deviceDisconnected) case .operationInProgress: self = .waitingForResponse + case .deviceConnected: + self = .ready } } var actionText: String { - switch self { - case .noKeyAvailable: - return "Create key" - default: - return "Unlock" - } + "Unlock" } var requiresDescription: Bool { @@ -137,7 +134,7 @@ enum ClientState { var allowsAction: Bool { switch self { - case .requestingStatus, .deviceNotAvailable, .waitingForResponse: + case .requestingStatus, .deviceNotAvailable, .waitingForResponse, .noKeyAvailable: return false default: return true diff --git a/Sesame/ContentView.swift b/Sesame/ContentView.swift index 97fed5a..41dff83 100644 --- a/Sesame/ContentView.swift +++ b/Sesame/ContentView.swift @@ -7,6 +7,9 @@ struct ContentView: View { @AppStorage("counter") var nextMessageCounter: Int = 0 + + @State + var keyManager = KeyManagement() @State var state: ClientState = .noKeyAvailable @@ -20,6 +23,12 @@ struct ContentView: View { @State private var responseTime: Date? = nil + @State + private var showKeySheet = false + + @State + private var showHistorySheet = false + var isPerformingRequests: Bool { hasActiveRequest || state == .waitingForResponse @@ -28,7 +37,7 @@ struct ContentView: View { var buttonBackground: Color { state.allowsAction ? .white.opacity(0.2) : - .gray.opacity(0.2) + .black.opacity(0.2) } let buttonBorderWidth: CGFloat = 3 @@ -39,16 +48,44 @@ struct ContentView: View { 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("Keys", action: { showKeySheet = 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() if state.requiresDescription { Text(state.description) .padding() } Button(state.actionText, action: mainButtonPressed) - .frame(width: buttonWidth, height: buttonWidth, alignment: .center) + .frame(width: buttonWidth, + height: buttonWidth) .background(buttonBackground) .cornerRadius(buttonWidth / 2) .overlay(RoundedRectangle(cornerRadius: buttonWidth / 2).stroke(lineWidth: buttonBorderWidth).foregroundColor(buttonColor)) @@ -57,8 +94,9 @@ struct ContentView: View { .disabled(!state.allowsAction) .padding(.bottom, (geo.size.width-buttonWidth) / 2) } + .background(state.color) .onAppear { - if KeyManagement.hasKey { + if keyManager.hasAllKeys { state = .requestingStatus } startRegularStatusUpdates() @@ -67,20 +105,19 @@ struct ContentView: View { endRegularStatusUpdates() } .frame(width: geo.size.width, height: geo.size.height) - .background(state.color) .animation(.easeInOut, value: state.color) + .sheet(isPresented: $showKeySheet) { + KeyView(keyManager: $keyManager) + } } } func mainButtonPressed() { - guard let key = KeyManagement.key?.remote else { - generateKey() + guard let key = keyManager.get(.remoteKey), + let token = keyManager.get(.authToken)?.data else { return } - sendMessage(using: key) - } - - func sendMessage(using key: SymmetricKey) { + let count = UInt32(nextMessageCounter) let now = Date() let content = Message.Content( @@ -90,7 +127,7 @@ struct ContentView: View { state = .waitingForResponse print("Sending message \(count)") Task { - let (newState, message) = try await server.send(message) + let (newState, message) = await server.send(message, authToken: token) responseTime = now state = newState if let message = message { @@ -100,7 +137,7 @@ struct ContentView: View { } private func processResponse(_ message: Message, sendTime: Date) { - guard let key = KeyManagement.key?.device else { + guard let key = keyManager.get(.deviceKey) else { return } guard message.isValid(using: key) else { @@ -115,9 +152,13 @@ struct ContentView: View { 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") + print("Device time behind by at least \(Int(-time1 * 1000)) ms") + print("Device: \(deviceTime)") + print("Remote: \(now)") } else if time2 < 0 { - print("Device time behind by at least \(Int(-time2 * 1000)) ms ahead") + print("Device time ahead by at least \(Int(-time2 * 1000)) ms") + print("Device: \(deviceTime)") + print("Remote: \(now)") } else { print("Device time synchronized") } @@ -139,13 +180,16 @@ struct ContentView: View { } func checkDeviceStatus(_ timer: Timer) { + guard let authToken = keyManager.get(.authToken) else { + return + } guard !hasActiveRequest else { return } hasActiveRequest = true print("Checking device status") Task { - let newState = await server.deviceStatus() + let newState = await server.deviceStatus(authToken: authToken.data) hasActiveRequest = false switch state { case .noKeyAvailable: @@ -173,16 +217,6 @@ struct ContentView: View { } } } - - func generateKey() { - print("Regenerate key") - KeyManagement.generateNewKeys() - state = .requestingStatus - } - - func shareKey() { - - } } struct ContentView_Previews: PreviewProvider { diff --git a/Sesame/KeyManagement.swift b/Sesame/KeyManagement.swift index 7618a41..c6e8078 100644 --- a/Sesame/KeyManagement.swift +++ b/Sesame/KeyManagement.swift @@ -2,95 +2,154 @@ import Foundation import CryptoKit import SwiftUI -final class KeyManagement { +extension KeyManagement { - static let tag = "com.ch.sesame.key".data(using: .utf8)! + enum KeyType: String, Identifiable, CaseIterable { - private static let label = "sesame" + case deviceKey = "sesame-device" + case remoteKey = "sesame-remote" + case authToken = "sesame-remote-auth" - private static let keyType = kSecAttrKeyTypeEC + var id: String { + rawValue + } - private static let keyClass = kSecAttrKeyClassSymmetric + var displayName: String { + switch self { + case .deviceKey: + return "Device Key" + case .remoteKey: + return "Remote Key" + case .authToken: + return "Authentication Token" + } + } - private static let query: [String: Any] = [ - kSecClass as String: kSecClassInternetPassword, - kSecAttrAccount as String: "account", - kSecAttrServer as String: "christophhagen.de", - kSecAttrLabel as String: "sesame"] + var keyLength: SymmetricKeySize { + .bits256 + } + } +} - private static func loadKeys() -> Data? { - var query = query +extension KeyManagement.KeyType: CustomStringConvertible { + + var description: String { + displayName + } +} + +private struct KeyChain { + + private let keyType = kSecAttrKeyTypeEC + + private let keyClass = kSecAttrKeyClassSymmetric + + private let domain: String + + init(domain: String) { + self.domain = domain + } + + private func baseQuery(for type: KeyManagement.KeyType) -> [String : Any] { + [kSecClass as String: kSecClassInternetPassword, + kSecAttrAccount as String: type.rawValue, + kSecAttrServer as String: domain] + } + + func save(_ type: KeyManagement.KeyType, _ key: SymmetricKey) { + var query = baseQuery(for: type) + query[kSecValueData as String] = key.data + let status = SecItemAdd(query as CFDictionary, nil) + guard status == errSecSuccess else { + print("Failed to store \(type): \(status)") + return + } + print("\(type) saved to keychain") + } + + func load(_ type: KeyManagement.KeyType) -> SymmetricKey? { + var query = baseQuery(for: type) 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)") + print("Failed to get \(type): \(status)") return nil } let key = item as! CFData - print("Key loaded from keychain") - return key as Data + print("\(type) loaded from keychain") + return SymmetricKey(data: key as Data) } - private static func deleteKeys() { - let status = SecItemDelete(query as CFDictionary) + func delete(_ type: KeyManagement.KeyType) { + let status = SecItemDelete(baseQuery(for: type) as CFDictionary) guard status == errSecSuccess || status == errSecItemNotFound else { - print("Failed to remove key: \(status)") + print("Failed to remove \(type): \(status)") return } - print("Key removed from keychain") + print("\(type) removed from keychain") } - 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 - } - print("Key saved to keychain") - } - - private static var keyData: Data? = loadKeys() { - didSet { - guard let data = keyData else { - deleteKeys() - return - } - saveKeys(data) - } - } - - static var hasKey: Bool { - key != nil - } - - private(set) static var key: (device: SymmetricKey, remote: SymmetricKey)? { - get { - guard let data = keyData else { - return nil - } - let device = SymmetricKey(data: data.prefix(32)) - let remote = SymmetricKey(data: data.advanced(by: 32)) - return (device, remote) - } - set { - guard let key = newValue else { - keyData = nil - return - } - keyData = key.device.data + key.remote.data - } - } - - static func generateNewKeys() { - let device = SymmetricKey(size: .bits256) - let remote = SymmetricKey(size: .bits256) - key = (device, remote) - print("New keys:") - print("Device: \(device.data.hexEncoded)") - print("Remote: \(remote.data.hexEncoded)") + func has(_ type: KeyManagement.KeyType) -> Bool { + load(type) != nil + } +} + +final class KeyManagement: ObservableObject { + + private let keyChain: KeyChain + + @Published + private(set) var hasRemoteKey = false + + @Published + private(set) var hasDeviceKey = false + + @Published + private(set) var hasAuthToken = false + + var hasAllKeys: Bool { + hasRemoteKey && hasDeviceKey && hasAuthToken + } + + init() { + self.keyChain = KeyChain(domain: "christophhagen.de") + updateKeyStates() + } + + func has(_ type: KeyType) -> Bool { + switch type { + case .deviceKey: + return hasDeviceKey + case .remoteKey: + return hasRemoteKey + case .authToken: + return hasAuthToken + } + } + + func get(_ type: KeyType) -> SymmetricKey? { + keyChain.load(type) + } + + func delete(_ type: KeyType) { + keyChain.delete(type) + updateKeyStates() + } + + func generate(_ type: KeyType) { + let key = SymmetricKey(size: type.keyLength) + if keyChain.has(type) { + keyChain.delete(type) + } + keyChain.save(type, key) + updateKeyStates() + } + + private func updateKeyStates() { + self.hasRemoteKey = keyChain.has(.remoteKey) + self.hasDeviceKey = keyChain.has(.deviceKey) + self.hasAuthToken = keyChain.has(.authToken) } } diff --git a/Sesame/KeyView.swift b/Sesame/KeyView.swift new file mode 100644 index 0000000..170e2cf --- /dev/null +++ b/Sesame/KeyView.swift @@ -0,0 +1,25 @@ +import SwiftUI + +struct KeyView: View { + + @Binding + var keyManager: KeyManagement + + var body: some View { + GeometryReader { geo in + VStack(alignment: .leading, spacing: 16) { + ForEach(KeyManagement.KeyType.allCases) { keyType in + SingleKeyView( + keyManager: $keyManager, + type: keyType) + } + }.padding() + } + } +} + +struct KeyView_Previews: PreviewProvider { + static var previews: some View { + KeyView(keyManager: .constant(KeyManagement())) + } +} diff --git a/Sesame/SingleKeyView.swift b/Sesame/SingleKeyView.swift new file mode 100644 index 0000000..4cdd976 --- /dev/null +++ b/Sesame/SingleKeyView.swift @@ -0,0 +1,54 @@ +import SwiftUI + +struct SingleKeyView: View { + + @State + private var needRefresh = false + + @Binding + var keyManager: KeyManagement + + let type: KeyManagement.KeyType + + private var generateText: String { + hasKey ? "Generate" : "Regenerate" + } + + var hasKey: Bool { + keyManager.has(type) + } + + var content: String { + keyManager.get(type)?.displayString ?? "-" + } + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + Text(type.displayName) + .bold() + Text(needRefresh ? content : content) + .font(.system(.body, design: .monospaced)) + HStack() { + Button(generateText) { + keyManager.generate(type) + needRefresh.toggle() + } + .padding() + Button("Copy") { + UIPasteboard.general.string = content + } + .disabled(!hasKey) + .padding() + Spacer() + } + } + } +} + +struct SingleKeyView_Previews: PreviewProvider { + static var previews: some View { + SingleKeyView( + keyManager: .constant(KeyManagement()), + type: .deviceKey) + } +} diff --git a/Sesame/SymmetricKey+Extensions.swift b/Sesame/SymmetricKey+Extensions.swift index d977084..e0f11ee 100644 --- a/Sesame/SymmetricKey+Extensions.swift +++ b/Sesame/SymmetricKey+Extensions.swift @@ -10,6 +10,10 @@ extension SymmetricKey { var base64: String { data.base64EncodedString() } + + var displayString: String { + data.hexEncoded.uppercased().split(by: 4).joined(separator: " ") + } var codeString: String { " {" + @@ -19,3 +23,19 @@ extension SymmetricKey { "}," } } + +extension String { + + func split(by length: Int) -> [String] { + var startIndex = self.startIndex + var results = [Substring]() + + while startIndex < self.endIndex { + let endIndex = self.index(startIndex, offsetBy: length, limitedBy: self.endIndex) ?? self.endIndex + results.append(self[startIndex..