Move to new key system

This commit is contained in:
Christoph Hagen 2022-04-09 17:43:33 +02:00
parent 6027228728
commit a4221c47f7
14 changed files with 821 additions and 440 deletions

View File

@ -16,7 +16,12 @@
884A45C927A43D7900D6E650 /* ClientState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 884A45C827A43D7900D6E650 /* ClientState.swift */; }; 884A45C927A43D7900D6E650 /* ClientState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 884A45C827A43D7900D6E650 /* ClientState.swift */; };
884A45CB27A464C000D6E650 /* SymmetricKey+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 884A45CA27A464C000D6E650 /* SymmetricKey+Extensions.swift */; }; 884A45CB27A464C000D6E650 /* SymmetricKey+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 884A45CA27A464C000D6E650 /* SymmetricKey+Extensions.swift */; };
884A45CD27A465F500D6E650 /* Client.swift in Sources */ = {isa = PBXBuildFile; fileRef = 884A45CC27A465F500D6E650 /* Client.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 */ /* End PBXBuildFile section */
/* Begin PBXFileReference section */ /* Begin PBXFileReference section */
@ -30,7 +35,11 @@
884A45C827A43D7900D6E650 /* ClientState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ClientState.swift; sourceTree = "<group>"; }; 884A45C827A43D7900D6E650 /* ClientState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ClientState.swift; sourceTree = "<group>"; };
884A45CA27A464C000D6E650 /* SymmetricKey+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SymmetricKey+Extensions.swift"; sourceTree = "<group>"; }; 884A45CA27A464C000D6E650 /* SymmetricKey+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SymmetricKey+Extensions.swift"; sourceTree = "<group>"; };
884A45CC27A465F500D6E650 /* Client.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Client.swift; sourceTree = "<group>"; }; 884A45CC27A465F500D6E650 /* Client.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Client.swift; sourceTree = "<group>"; };
884A45CE27A5402D00D6E650 /* Response.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Response.swift; sourceTree = "<group>"; }; 884A45CE27A5402D00D6E650 /* MessageResult.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageResult.swift; sourceTree = "<group>"; };
E24EE77127FDCCC00011CFD2 /* Data+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Data+Extensions.swift"; sourceTree = "<group>"; };
E24EE77327FF95920011CFD2 /* DeviceResponse.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeviceResponse.swift; sourceTree = "<group>"; };
E24EE77827FF95E00011CFD2 /* Message.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Message.swift; sourceTree = "<group>"; };
E24EE77A280058240011CFD2 /* Message+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Message+Extensions.swift"; sourceTree = "<group>"; };
/* End PBXFileReference section */ /* End PBXFileReference section */
/* Begin PBXFrameworksBuildPhase section */ /* Begin PBXFrameworksBuildPhase section */
@ -38,6 +47,7 @@
isa = PBXFrameworksBuildPhase; isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647; buildActionMask = 2147483647;
files = ( files = (
E24EE77727FF95C00011CFD2 /* NIOCore in Frameworks */,
); );
runOnlyForDeploymentPostprocessing = 0; runOnlyForDeploymentPostprocessing = 0;
}; };
@ -64,15 +74,19 @@
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
884A45B6279F48C100D6E650 /* SesameApp.swift */, 884A45B6279F48C100D6E650 /* SesameApp.swift */,
E24EE77827FF95E00011CFD2 /* Message.swift */,
E24EE77A280058240011CFD2 /* Message+Extensions.swift */,
884A45B8279F48C100D6E650 /* ContentView.swift */, 884A45B8279F48C100D6E650 /* ContentView.swift */,
884A45CC27A465F500D6E650 /* Client.swift */, 884A45CC27A465F500D6E650 /* Client.swift */,
884A45CE27A5402D00D6E650 /* Response.swift */, 884A45CE27A5402D00D6E650 /* MessageResult.swift */,
884A45C827A43D7900D6E650 /* ClientState.swift */, 884A45C827A43D7900D6E650 /* ClientState.swift */,
884A45C627A429EF00D6E650 /* ShareSheet.swift */, 884A45C627A429EF00D6E650 /* ShareSheet.swift */,
884A45C4279F4BBE00D6E650 /* KeyManagement.swift */, 884A45C4279F4BBE00D6E650 /* KeyManagement.swift */,
E24EE77327FF95920011CFD2 /* DeviceResponse.swift */,
884A45CA27A464C000D6E650 /* SymmetricKey+Extensions.swift */, 884A45CA27A464C000D6E650 /* SymmetricKey+Extensions.swift */,
884A45BA279F48C300D6E650 /* Assets.xcassets */, 884A45BA279F48C300D6E650 /* Assets.xcassets */,
884A45BC279F48C300D6E650 /* Preview Content */, 884A45BC279F48C300D6E650 /* Preview Content */,
E24EE77127FDCCC00011CFD2 /* Data+Extensions.swift */,
); );
path = Sesame; path = Sesame;
sourceTree = "<group>"; sourceTree = "<group>";
@ -101,6 +115,9 @@
dependencies = ( dependencies = (
); );
name = Sesame; name = Sesame;
packageProductDependencies = (
E24EE77627FF95C00011CFD2 /* NIOCore */,
);
productName = Sesame; productName = Sesame;
productReference = 884A45B3279F48C100D6E650 /* Sesame.app */; productReference = 884A45B3279F48C100D6E650 /* Sesame.app */;
productType = "com.apple.product-type.application"; productType = "com.apple.product-type.application";
@ -129,6 +146,9 @@
Base, Base,
); );
mainGroup = 884A45AA279F48C100D6E650; mainGroup = 884A45AA279F48C100D6E650;
packageReferences = (
E24EE77527FF95C00011CFD2 /* XCRemoteSwiftPackageReference "swift-nio" */,
);
productRefGroup = 884A45B4279F48C100D6E650 /* Products */; productRefGroup = 884A45B4279F48C100D6E650 /* Products */;
projectDirPath = ""; projectDirPath = "";
projectRoot = ""; projectRoot = "";
@ -155,10 +175,14 @@
isa = PBXSourcesBuildPhase; isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647; buildActionMask = 2147483647;
files = ( files = (
884A45CF27A5402D00D6E650 /* Response.swift in Sources */, 884A45CF27A5402D00D6E650 /* MessageResult.swift in Sources */,
884A45B9279F48C100D6E650 /* ContentView.swift in Sources */, 884A45B9279F48C100D6E650 /* ContentView.swift in Sources */,
884A45CD27A465F500D6E650 /* Client.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 */, 884A45CB27A464C000D6E650 /* SymmetricKey+Extensions.swift in Sources */,
E24EE77927FF95E00011CFD2 /* Message.swift in Sources */,
884A45C927A43D7900D6E650 /* ClientState.swift in Sources */, 884A45C927A43D7900D6E650 /* ClientState.swift in Sources */,
884A45B7279F48C100D6E650 /* SesameApp.swift in Sources */, 884A45B7279F48C100D6E650 /* SesameApp.swift in Sources */,
884A45C5279F4BBE00D6E650 /* KeyManagement.swift in Sources */, 884A45C5279F4BBE00D6E650 /* KeyManagement.swift in Sources */,
@ -367,6 +391,25 @@
defaultConfigurationName = Release; defaultConfigurationName = Release;
}; };
/* End XCConfigurationList section */ /* 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 */; rootObject = 884A45AB279F48C100D6E650 /* Project object */;
} }

View File

@ -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
}

View File

@ -4,10 +4,73 @@
<dict> <dict>
<key>SchemeUserState</key> <key>SchemeUserState</key>
<dict> <dict>
<key>BitcoinKit (Playground) 1.xcscheme</key>
<dict>
<key>isShown</key>
<false/>
<key>orderHint</key>
<integer>5</integer>
</dict>
<key>BitcoinKit (Playground) 2.xcscheme</key>
<dict>
<key>isShown</key>
<false/>
<key>orderHint</key>
<integer>6</integer>
</dict>
<key>BitcoinKit (Playground) 3.xcscheme</key>
<dict>
<key>isShown</key>
<false/>
<key>orderHint</key>
<integer>7</integer>
</dict>
<key>BitcoinKit (Playground) 4.xcscheme</key>
<dict>
<key>isShown</key>
<false/>
<key>orderHint</key>
<integer>8</integer>
</dict>
<key>BitcoinKit (Playground) 5.xcscheme</key>
<dict>
<key>isShown</key>
<false/>
<key>orderHint</key>
<integer>9</integer>
</dict>
<key>BitcoinKit (Playground).xcscheme</key>
<dict>
<key>isShown</key>
<false/>
<key>orderHint</key>
<integer>4</integer>
</dict>
<key>Demo (Playground) 1.xcscheme</key>
<dict>
<key>isShown</key>
<false/>
<key>orderHint</key>
<integer>2</integer>
</dict>
<key>Demo (Playground) 2.xcscheme</key>
<dict>
<key>isShown</key>
<false/>
<key>orderHint</key>
<integer>3</integer>
</dict>
<key>Demo (Playground).xcscheme</key>
<dict>
<key>isShown</key>
<false/>
<key>orderHint</key>
<integer>0</integer>
</dict>
<key>Sesame.xcscheme_^#shared#^_</key> <key>Sesame.xcscheme_^#shared#^_</key>
<dict> <dict>
<key>orderHint</key> <key>orderHint</key>
<integer>0</integer> <integer>1</integer>
</dict> </dict>
</dict> </dict>
</dict> </dict>

View File

@ -13,86 +13,88 @@ struct Client {
private enum RequestReponse: Error { private enum RequestReponse: Error {
case requestFailed case requestFailed
case unknownResponse case unknownResponseData(Data)
case unknownResponseString(String)
case success(UInt8) case success(UInt8)
} }
func deviceStatus() async throws -> ClientState { func deviceStatus() async -> ClientState {
let url = server.appendingPathComponent("status") let url = server.appendingPathComponent("status")
let request = URLRequest(url: url, cachePolicy: .reloadIgnoringLocalCacheData) let request = URLRequest(url: url, cachePolicy: .reloadIgnoringLocalCacheData)
let response = await integerReponse(to: request) let response = await integerReponse(to: request)
switch response { switch response {
case .requestFailed: case .requestFailed:
return .statusRequestFailed return .deviceNotAvailable(.serverNotReached)
case .unknownResponse: case .unknownResponseData(let data):
return .unknownDeviceStatus return .internalError("Unknown status (\(data.count) bytes)")
case .unknownResponseString(let string):
return .internalError("Unknown status (\(string.prefix(15)))")
case .success(let int): case .success(let int):
switch int { switch int {
case 0: case 0:
return .deviceDisconnected return .deviceNotAvailable(.deviceDisconnected)
case 1: case 1:
return .deviceConnected return .ready
default: default:
print("Unexpected device status '\(int)'") return .internalError("Invalid status: \(int)")
return .unknownDeviceStatus
} }
} }
} }
func keyResponse(key: SymmetricKey, id: Int) async throws -> ClientState { func send(_ message: Message) async throws -> (state: ClientState, response: Message?) {
let url = server.appendingPathComponent("key/\(id)") let url = server.appendingPathComponent("message")
var request = URLRequest(url: url) var request = URLRequest(url: url)
request.httpBody = key.data request.httpBody = message.encoded
request.httpMethod = "POST" request.httpMethod = "POST"
let response = await integerReponse(to: request) guard let data = await fulfill(request) else {
switch response { return (.deviceNotAvailable(.serverNotReached), nil)
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 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<Data, RequestReponse> { private func fulfill(_ request: URLRequest) async -> Data? {
do { do {
let (data, response) = try await URLSession.shared.data(for: request) let (data, response) = try await URLSession.shared.data(for: request)
guard let code = (response as? HTTPURLResponse)?.statusCode else { guard let code = (response as? HTTPURLResponse)?.statusCode else {
print("No response from server") print("No response from server")
return .failure(.requestFailed) return nil
} }
guard code == 200 else { guard code == 200 else {
print("Invalid server response \(code)") print("Invalid server response \(code)")
return .failure(.requestFailed) return nil
} }
return .success(data) return data
} catch { } catch {
print("Request failed: \(error)") print("Request failed: \(error)")
return .failure(.requestFailed) return nil
} }
} }
private func integerReponse(to request: URLRequest) async -> RequestReponse { private func integerReponse(to request: URLRequest) async -> RequestReponse {
let response = await fulfill(request) guard let data = await fulfill(request) else {
switch response { return .requestFailed
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 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)
} }
} }

View File

@ -1,137 +1,156 @@
import Foundation import Foundation
import SwiftUI import SwiftUI
enum ClientState { enum ConnectionError {
case serverNotReached
/// 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)
case deviceDisconnected 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 /// The message is being transmitted and a response is awaited
case deviceConnected
/// The key is being transmitted and a response is awaited
case waitingForResponse case waitingForResponse
/// The transmitted key was rejected (multiple possible reasons) /// The transmitted message was rejected (multiple possible reasons)
case keyRejected case messageRejected(RejectionCause)
/// Internal errors with the implementation
case internalError
/// The configuration of the devices doesn't match
case configurationError
/// The device responded that the opening action was started /// The device responded that the opening action was started
case openSesame case openSesame
/// All keys have been used case internalError(String)
case allKeysUsed
var canSendKey: Bool { var canSendKey: Bool {
switch self { switch self {
case .deviceConnected, .openSesame, .keyRejected: case .ready, .openSesame, .messageRejected:
return true return true
default: default:
return false return false
} }
} }
init(keyResult: KeyResult) { init(keyResult: MessageResult) {
switch keyResult { switch keyResult {
case .textReceived, .unexpectedSocketEvent, .unknownDeviceError: case .messageAuthenticationFailed:
self = .unknownDeviceStatus self = .messageRejected(.invalidAuthentication)
case .invalidPayloadSize, .invalidKeyIndex, .invalidKey: case .messageTimeMismatch:
self = .configurationError self = .messageRejected(.invalidTime)
case .keyAlreadyUsed, .keyWasSkipped: case .messageCounterInvalid:
self = .keyRejected self = .messageRejected(.invalidCounter)
case .keyAccepted: case .deviceTimedOut:
self = .messageRejected(.timeout)
case .messageAccepted:
self = .openSesame self = .openSesame
case .noBodyData, .corruptkeyData: case .noBodyData, .invalidMessageData, .textReceived, .unexpectedSocketEvent:
self = .internalError self = .internalError(keyResult.description)
case .deviceNotConnected, .deviceTimedOut: case .deviceNotConnected:
self = .deviceDisconnected 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 { var openButtonText: String {
switch self { switch self {
case .initial, .statusRequestFailed, .unknownDeviceStatus, .deviceDisconnected, .newKeysGenerated, .configurationError, .internalError: case .noKeyAvailable:
return "Connect" return "Create key"
case .allKeysUsed, .noKeysAvailable: default:
return "Disabled"
case .deviceConnected, .keyRejected, .openSesame:
return "Unlock" return "Unlock"
case .waitingForResponse:
return "Unlocking..."
} }
} }
var openButtonColor: Color { var openButtonColor: Color {
switch self { switch self {
case .initial, .newKeysGenerated, .statusRequestFailed, .waitingForResponse: case .noKeyAvailable, .requestingStatus:
return .yellow return .yellow
case .noKeysAvailable, .allKeysUsed, .deviceDisconnected, .unknownDeviceStatus, .keyRejected, .configurationError, .internalError: case .deviceNotAvailable, .messageRejected, .internalError:
return .red return .red
case .deviceConnected, .openSesame: case .ready, .waitingForResponse, .openSesame:
return .green return .green
} }
} }
var openActionIsEnabled: Bool { var openButtonIsEnabled: Bool {
switch self { switch self {
case .allKeysUsed, .noKeysAvailable, .waitingForResponse: case .requestingStatus, .deviceNotAvailable, .waitingForResponse:
return false return false
default: default:
return true 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)"
}
}
} }

View File

@ -1,154 +1,189 @@
import SwiftUI import SwiftUI
import CryptoKit import CryptoKit
let keyManager = try! KeyManagement()
let server = Client(server: URL(string: "https://christophhagen.de/sesame/")!) let server = Client(server: URL(string: "https://christophhagen.de/sesame/")!)
struct ContentView: View { struct ContentView: View {
@AppStorage("counter")
var nextMessageCounter: Int = 0
@State var state: ClientState = .initial @State
var state: ClientState = .noKeyAvailable
var canShareKey = false
@State
@State var showNewKeyWarning = false private var timer: Timer?
@State var showKeyGenerationFailedWarning = false @State
private var hasActiveRequest = false
@State var showShareSheetForNewKeys = false
@State
@State var activeRequestCount = 0 private var responseTime: Date? = nil
var isPerformingRequests: Bool { var isPerformingRequests: Bool {
activeRequestCount > 0 hasActiveRequest ||
state == .waitingForResponse
} }
var keyText: String { var buttonBackground: Color {
let totalKeys = keyManager.numberOfKeys state.openButtonIsEnabled ?
guard totalKeys > 0 else { .white.opacity(0.2) :
return "No keys available" .gray.opacity(0.2)
}
let unusedKeys = keyManager.unusedKeyCount
guard unusedKeys > 0 else {
return "All keys used"
}
return "\(totalKeys - unusedKeys) / \(totalKeys) keys used"
} }
let buttonBorderWidth: CGFloat = 3
var buttonColor: Color {
state.openButtonIsEnabled ? .white : .gray
}
private let buttonWidth: CGFloat = 200 private let buttonWidth: CGFloat = 200
private let topButtonHeight: CGFloat = 60 private let topButtonHeight: CGFloat = 60
var body: some View { var body: some View {
VStack(spacing: 20) { GeometryReader { geo in
Text(keyText) VStack(spacing: 20) {
Button("Generate new keys", action: { Spacer()
showNewKeyWarning = true HStack {
print("Key regeneration requested") if isPerformingRequests {
}) ProgressView()
.padding() .progressViewStyle(CircularProgressViewStyle())
.frame(width: buttonWidth, height: topButtonHeight) }
.background(.blue) Text(state.description)
.foregroundColor(.white) .padding()
.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())
} }
Text(state.description) Button(state.openButtonText, action: mainButtonPressed)
.padding() .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) .onAppear {
.frame(width: buttonWidth, height: 80, alignment: .center) if KeyManagement.hasKey {
.background(state.openButtonColor) state = .requestingStatus
.cornerRadius(100) }
.foregroundColor(.white) startRegularStatusUpdates()
.font(.title2) }
.disabled(!state.openActionIsEnabled) .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() { func mainButtonPressed() {
print("Main button pressed") guard let key = KeyManagement.key?.remote else {
if state.canSendKey { generateKey()
sendKey()
} else {
checkInitialDeviceStatus()
}
}
func sendKey() {
guard let key = keyManager.useNextKey() else {
state = .allKeysUsed
return return
} }
state = .waitingForResponse sendMessage(using: key)
activeRequestCount += 1
print("Sending key \(key.id)")
Task {
let newState = try await server.keyResponse(key: key.key, id: key.id)
activeRequestCount -= 1
state = newState
}
} }
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") print("Checking device status")
Task { Task {
do { let newState = await server.deviceStatus()
activeRequestCount += 1 hasActiveRequest = false
let newState = try await server.deviceStatus() switch state {
activeRequestCount -= 1 case .noKeyAvailable:
print("Device status: \(newState)") return
switch newState { case .requestingStatus, .deviceNotAvailable, .ready:
case .noKeysAvailable, .allKeysUsed: state = newState
case .waitingForResponse:
return
case .messageRejected, .openSesame, .internalError:
guard let time = responseTime else {
state = newState
return 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 state = newState
} }
} catch {
print("Failed to get device status: \(error)")
state = .statusRequestFailed
} }
} }
} }
func regenerateKeys() { func generateKey() {
print("Regenerate keys") print("Regenerate key")
do { KeyManagement.generateNewKeys()
try keyManager.regenerateKeys() state = .requestingStatus
state = .newKeysGenerated
showKeyGenerationFailedWarning = false
showShareSheetForNewKeys = true
checkInitialDeviceStatus()
} catch {
state = .noKeysAvailable
showKeyGenerationFailedWarning = true
showShareSheetForNewKeys = false
}
} }
func shareKey() { func shareKey() {
@ -162,3 +197,14 @@ struct ContentView_Previews: PreviewProvider {
.previewDevice("iPhone 8") .previewDevice("iPhone 8")
} }
} }
extension Date {
var timestamp: UInt32 {
UInt32(timeIntervalSince1970.rounded())
}
init(timestamp: UInt32) {
self.init(timeIntervalSince1970: TimeInterval(timestamp))
}
}

View File

@ -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)!
}
}
}

View File

@ -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
}
}

View File

@ -3,116 +3,94 @@ import CryptoKit
import SwiftUI import SwiftUI
final class KeyManagement { final class KeyManagement {
static let securityKeySize: SymmetricKeySize = .bits128 static let tag = "com.ch.sesame.key".data(using: .utf8)!
enum KeyError: Error { private static let label = "sesame"
/// Keys which are already in use can't be exported
case exportAttemptOfUsedKeys private static let keyType = kSecAttrKeyTypeEC
}
private static let keyClass = kSecAttrKeyClassSymmetric
static var documentsDirectory: URL {
let paths = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask) private static let query: [String: Any] = [
return paths[0] kSecClass as String: kSecClassInternetPassword,
} kSecAttrAccount as String: "account",
kSecAttrServer as String: "christophhagen.de",
private let keyFile = KeyManagement.documentsDirectory.appendingPathComponent("keys") ]//kSecAttrLabel as String: "sesame"]
let exportFile = KeyManagement.documentsDirectory.appendingPathComponent("export.cpp") private static func loadKeys() -> Data? {
var query = query
private var keys: [(key: SymmetricKey, used: Bool)] { query[kSecReturnData as String] = kCFBooleanTrue
didSet {
do { var item: CFTypeRef?
try saveKeys() let status = SecItemCopyMatching(query as CFDictionary, &item)
} catch { guard status == errSecSuccess else {
print("Failed to save changed keys: \(error)") print("Failed to get key: \(status)")
}
}
}
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 {
return nil return nil
} }
return index let key = item as! CFData
print("Key loaded from keychain")
return key as Data
} }
init() throws { private static func deleteKeys() {
guard FileManager.default.fileExists(atPath: keyFile.path) else { let status = SecItemDelete(query as CFDictionary)
self.keys = [] guard status == errSecSuccess || status == errSecItemNotFound else {
print("Failed to remove key: \(status)")
return return
} }
let content = try String(contentsOf: keyFile) print("Key removed from keychain")
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")
} }
func useNextKey() -> (key: SymmetricKey, id: Int)? { private static func saveKeys(_ data: Data) {
guard let index = nextKeyId else { var query = query
return nil 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 print("Key saved to keychain")
keys[index].used = true
return (key, index)
} }
func regenerateKeys(count: Int = 100) throws { private static var keyData: Data? = loadKeys() {
self.keys = Self.generateKeys(count: count) didSet {
.map { ($0, false) } guard let data = keyData else {
let keyString = keys.map { $0.key.codeString }.joined(separator: "\n") deleteKeys()
try keyString.write(to: exportFile, atomically: false, encoding: .utf8) return
}
private func saveKeys() throws {
let content = keys.map { key, used -> String in
let keyString = key.withUnsafeBytes {
return Data(Array($0)).base64EncodedString()
} }
return keyString + ":" + (used ? "1" : "0") saveKeys(data)
}.joined(separator: "\n")
try content.write(to: keyFile, atomically: true, encoding: .utf8)
print("Keys saved")
}
static func generateKeys(count: Int = 100) -> [SymmetricKey] {
(0..<count).map { _ in
SymmetricKey(size: securityKeySize)
} }
} }
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)")
}
} }

View File

@ -0,0 +1,40 @@
//
// Message+Extensions.swift
// Sesame
//
// Created by CH on 08.04.22.
//
import Foundation
import CryptoKit
extension Message {
static var length: Int {
SHA256Digest.byteCount + Content.length
}
init<T: Sequence>(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<SHA256>.isValidAuthenticationCode(mac, authenticating: content.encoded, using: key)
}
}
extension Message.Content {
func authenticate(using key: SymmetricKey) -> Message {
let mac = HMAC<SHA256>.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<SHA256>.authenticationCode(for: encoded, using: key)
return Data(mac.map { $0 }) + encoded
}
}

78
Sesame/Message.swift Normal file
View File

@ -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<T: Sequence>(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<UInt32>.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<T: Sequence>(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)
}
}
}

View File

@ -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"
}
}
}

View File

@ -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"
}
}
}