Initial version

This commit is contained in:
Christoph Hagen
2022-01-29 18:59:42 +01:00
parent bf6061a6d0
commit b8a04fdf57
17 changed files with 1185 additions and 0 deletions

View File

@ -0,0 +1,11 @@
{
"colors" : [
{
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

View File

@ -0,0 +1,98 @@
{
"images" : [
{
"idiom" : "iphone",
"scale" : "2x",
"size" : "20x20"
},
{
"idiom" : "iphone",
"scale" : "3x",
"size" : "20x20"
},
{
"idiom" : "iphone",
"scale" : "2x",
"size" : "29x29"
},
{
"idiom" : "iphone",
"scale" : "3x",
"size" : "29x29"
},
{
"idiom" : "iphone",
"scale" : "2x",
"size" : "40x40"
},
{
"idiom" : "iphone",
"scale" : "3x",
"size" : "40x40"
},
{
"idiom" : "iphone",
"scale" : "2x",
"size" : "60x60"
},
{
"idiom" : "iphone",
"scale" : "3x",
"size" : "60x60"
},
{
"idiom" : "ipad",
"scale" : "1x",
"size" : "20x20"
},
{
"idiom" : "ipad",
"scale" : "2x",
"size" : "20x20"
},
{
"idiom" : "ipad",
"scale" : "1x",
"size" : "29x29"
},
{
"idiom" : "ipad",
"scale" : "2x",
"size" : "29x29"
},
{
"idiom" : "ipad",
"scale" : "1x",
"size" : "40x40"
},
{
"idiom" : "ipad",
"scale" : "2x",
"size" : "40x40"
},
{
"idiom" : "ipad",
"scale" : "1x",
"size" : "76x76"
},
{
"idiom" : "ipad",
"scale" : "2x",
"size" : "76x76"
},
{
"idiom" : "ipad",
"scale" : "2x",
"size" : "83.5x83.5"
},
{
"idiom" : "ios-marketing",
"scale" : "1x",
"size" : "1024x1024"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

View File

@ -0,0 +1,6 @@
{
"info" : {
"author" : "xcode",
"version" : 1
}
}

105
Sesame/Client.swift Normal file
View File

@ -0,0 +1,105 @@
import Foundation
import CryptoKit
struct Client {
let server: URL
private let delegate = NeverCacheDelegate()
init(server: URL) {
self.server = server
}
private enum RequestReponse: Error {
case requestFailed
case unknownResponse
case success(UInt8)
}
func deviceStatus() async throws -> 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
case .success(let int):
switch int {
case 0:
return .deviceDisconnected
case 1:
return .deviceConnected
default:
print("Unexpected device status '\(int)'")
return .unknownDeviceStatus
}
}
}
func keyResponse(key: SymmetricKey, id: Int) async throws -> ClientState {
let url = server.appendingPathComponent("key/\(id)")
var request = URLRequest(url: url)
request.httpBody = key.data
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)
}
}
private func fulfill(_ request: URLRequest) async -> Result<Data, RequestReponse> {
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)
}
guard code == 200 else {
print("Invalid server response \(code)")
return .failure(.requestFailed)
}
return .success(data)
} catch {
print("Request failed: \(error)")
return .failure(.requestFailed)
}
}
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)
}
}
}
class NeverCacheDelegate: NSObject, NSURLConnectionDataDelegate {
func connection(_ connection: NSURLConnection, willCacheResponse cachedResponse: CachedURLResponse) -> CachedURLResponse? {
return nil
}
}

137
Sesame/ClientState.swift Normal file
View File

@ -0,0 +1,137 @@
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)
case deviceDisconnected
/// The device is connected and ready to receive a key
case deviceConnected
/// The key 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 device responded that the opening action was started
case openSesame
/// All keys have been used
case allKeysUsed
var canSendKey: Bool {
switch self {
case .deviceConnected, .openSesame, .keyRejected:
return true
default:
return false
}
}
init(keyResult: KeyResult) {
switch keyResult {
case .textReceived, .unexpectedSocketEvent, .unknownDeviceError:
self = .unknownDeviceStatus
case .invalidPayloadSize, .invalidKeyIndex, .invalidKey:
self = .configurationError
case .keyAlreadyUsed, .keyWasSkipped:
self = .keyRejected
case .keyAccepted:
self = .openSesame
case .noBodyData, .corruptkeyData:
self = .internalError
case .deviceNotConnected, .deviceTimedOut:
self = .deviceDisconnected
}
}
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:
return "Unlock"
case .waitingForResponse:
return "Unlocking..."
}
}
var openButtonColor: Color {
switch self {
case .initial, .newKeysGenerated, .statusRequestFailed, .waitingForResponse:
return .yellow
case .noKeysAvailable, .allKeysUsed, .deviceDisconnected, .unknownDeviceStatus, .keyRejected, .configurationError, .internalError:
return .red
case .deviceConnected, .openSesame:
return .green
}
}
var openActionIsEnabled: Bool {
switch self {
case .allKeysUsed, .noKeysAvailable, .waitingForResponse:
return false
default:
return true
}
}
}

164
Sesame/ContentView.swift Normal file
View File

@ -0,0 +1,164 @@
import SwiftUI
import CryptoKit
let keyManager = try! KeyManagement()
let server = Client(server: URL(string: "https://christophhagen.de/sesame/")!)
struct ContentView: View {
@State var state: ClientState = .initial
var canShareKey = false
@State var showNewKeyWarning = false
@State var showKeyGenerationFailedWarning = false
@State var showShareSheetForNewKeys = false
@State var activeRequestCount = 0
var isPerformingRequests: Bool {
activeRequestCount > 0
}
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"
}
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())
}
Text(state.description)
.padding()
}
Button(state.openButtonText, action: mainButtonPressed)
.frame(width: buttonWidth, height: 80, alignment: .center)
.background(state.openButtonColor)
.cornerRadius(100)
.foregroundColor(.white)
.font(.title2)
.disabled(!state.openActionIsEnabled)
}
.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
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
}
}
func checkInitialDeviceStatus() {
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:
return
default:
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 shareKey() {
}
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView()
.previewDevice("iPhone 8")
}
}

118
Sesame/KeyManagement.swift Normal file
View File

@ -0,0 +1,118 @@
import Foundation
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 {
return nil
}
return index
}
init() throws {
guard FileManager.default.fileExists(atPath: keyFile.path) else {
self.keys = []
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")
}
func useNextKey() -> (key: SymmetricKey, id: Int)? {
guard let index = nextKeyId else {
return nil
}
let key = keys[index].key
keys[index].used = true
return (key, index)
}
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()
}
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)
}
}
}

View File

@ -0,0 +1,6 @@
{
"info" : {
"author" : "xcode",
"version" : 1
}
}

80
Sesame/Response.swift Normal file
View File

@ -0,0 +1,80 @@
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"
}
}
}

17
Sesame/SesameApp.swift Normal file
View File

@ -0,0 +1,17 @@
//
// SesameApp.swift
// Sesame
//
// Created by iMac on 24.01.22.
//
import SwiftUI
@main
struct SesameApp: App {
var body: some Scene {
WindowGroup {
ContentView()
}
}
}

21
Sesame/ShareSheet.swift Normal file
View File

@ -0,0 +1,21 @@
import SwiftUI
extension UIApplication {
static let keyWindow = keyWindowScene?.windows.filter(\.isKeyWindow).first
static let keyWindowScene = shared.connectedScenes.first { $0.activationState == .foregroundActive } as? UIWindowScene
}
extension View {
func shareSheet(isPresented: Binding<Bool>, items: [Any]) -> some View {
guard isPresented.wrappedValue else { return self }
let activityViewController = UIActivityViewController(activityItems: items, applicationActivities: nil)
let presentedViewController = UIApplication.keyWindow?.rootViewController?.presentedViewController ?? UIApplication.keyWindow?.rootViewController
activityViewController.completionWithItemsHandler = { _, _, _, _ in isPresented.wrappedValue = false }
presentedViewController?.present(activityViewController, animated: true)
return self
}
}

View File

@ -0,0 +1,21 @@
import Foundation
import CryptoKit
extension SymmetricKey {
var data: Data {
withUnsafeBytes { Data(Array($0)) }
}
var base64: String {
data.base64EncodedString()
}
var codeString: String {
" {" +
withUnsafeBytes {
return Data(Array($0))
}.map(String.init).joined(separator: ", ") +
"},"
}
}