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 */; };
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 = "<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>"; };
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 */
/* 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 = "<group>";
@ -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 */;
}

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>
<key>SchemeUserState</key>
<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>
<dict>
<key>orderHint</key>
<integer>0</integer>
<integer>1</integer>
</dict>
</dict>
</dict>

View File

@ -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<Data, RequestReponse> {
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)
}
}

View File

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

View File

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

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
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..<count).map { _ in
SymmetricKey(size: securityKeySize)
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)")
}
}

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