Delete history, allow retry

This commit is contained in:
Christoph Hagen
2023-08-14 10:39:29 +02:00
parent 95ece1ddcc
commit 7a443d51b3
26 changed files with 440 additions and 220 deletions

View File

@ -0,0 +1,93 @@
import Foundation
import CryptoKit
final class Client {
// TODO: Use or delete
private let delegate = NeverCacheDelegate()
init() {}
func deviceStatus(authToken: Data, server: String) async -> ClientState {
await send(path: .getDeviceStatus, server: server, data: authToken).state
}
func sendMessageOverLocalNetwork(_ message: Message, server: String) async -> (state: ClientState, response: Message?) {
let data = message.encoded.hexEncoded
guard let url = URL(string: server + "message?m=\(data)") else {
return (.internalError("Invalid server url"), nil)
}
var request = URLRequest(url: url)
request.httpMethod = "POST"
return await requestAndDecode(request)
}
func send(_ message: Message, server: String, authToken: Data) async -> (state: ClientState, response: Message?) {
let serverMessage = ServerMessage(authToken: authToken, message: message)
return await send(path: .postMessage, server: server, data: serverMessage.encoded)
}
private func send(path: RouteAPI, server: String, data: Data) async -> (state: ClientState, response: Message?) {
guard let url = URL(string: server) else {
return (.internalError("Invalid server url"), nil)
}
let fullUrl = url.appendingPathComponent(path.rawValue)
return await send(to: fullUrl, data: data)
}
private func send(to url: URL, data: Data) async -> (state: ClientState, response: Message?) {
var request = URLRequest(url: url)
request.httpBody = data
request.httpMethod = "POST"
request.timeoutInterval = 10
return await requestAndDecode(request)
}
private func requestAndDecode(_ request: URLRequest) async -> (state: ClientState, response: Message?) {
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 {
if data.count != 1 {
print("Device response with only \(data.count) bytes")
}
return (result, nil)
}
let messageData = Array(data.advanced(by: 1))
let message = Message(decodeFrom: messageData)
return (result, message)
}
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 nil
}
guard code == 200 else {
print("Invalid server response \(code)")
return nil
}
return data
} catch {
print("Request failed: \(error)")
return nil
}
}
}
class NeverCacheDelegate: NSObject, NSURLConnectionDataDelegate {
func connection(_ connection: NSURLConnection, willCacheResponse cachedResponse: CachedURLResponse) -> CachedURLResponse? {
return nil
}
}

View File

@ -0,0 +1,371 @@
import Foundation
import SwiftUI
import SFSafeSymbols
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 invalidDeviceId
case invalidCounter
case invalidTime
case invalidAuthentication
case timeout
case missingKey
}
extension RejectionCause: CustomStringConvertible {
var description: String {
switch self {
case .invalidDeviceId:
return "Invalid device ID"
case .invalidCounter:
return "Invalid counter"
case .invalidTime:
return "Invalid time"
case .invalidAuthentication:
return "Invalid authentication"
case .timeout:
return "Device not responding"
case .missingKey:
return "No key to verify message"
}
}
}
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 message is being transmitted and a response is awaited
case waitingForResponse
/// The transmitted message was rejected (multiple possible reasons)
case messageRejected(RejectionCause)
case responseRejected(RejectionCause)
/// The device responded that the opening action was started
case openSesame
case internalError(String)
var canSendKey: Bool {
switch self {
case .ready, .openSesame, .messageRejected:
return true
default:
return false
}
}
init(keyResult: MessageResult) {
switch keyResult {
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 .messageDeviceInvalid:
self = .messageRejected(.invalidDeviceId)
case .noBodyData, .invalidMessageSize, .textReceived, .unexpectedSocketEvent, .invalidUrlParameter, .invalidResponseAuthentication:
print("Unexpected internal error: \(keyResult)")
self = .internalError(keyResult.description)
case .deviceNotConnected:
self = .deviceNotAvailable(.deviceDisconnected)
case .operationInProgress:
self = .waitingForResponse
case .deviceConnected:
self = .ready
}
}
var actionText: String {
switch self {
case .noKeyAvailable:
return "No key"
case .requestingStatus:
return "Checking..."
case .deviceNotAvailable(let connectionError):
switch connectionError {
case .serverNotReached:
return "Server not found"
case .deviceDisconnected:
return "Device disconnected"
}
case .ready:
return "Unlock"
case .waitingForResponse:
return "Unlocking..."
case .messageRejected(let rejectionCause):
switch rejectionCause {
case .invalidDeviceId:
return "Invalid device ID"
case .invalidCounter:
return "Invalid counter"
case .invalidTime:
return "Invalid timestamp"
case .invalidAuthentication:
return "Invalid signature"
case .timeout:
return "Device not responding"
case .missingKey:
return "Device key missing"
}
case .responseRejected(let rejectionCause):
switch rejectionCause {
case .invalidDeviceId:
return "Invalid device id (response)"
case .invalidCounter:
return "Invalid counter (response)"
case .invalidTime:
return "Invalid time (response)"
case .invalidAuthentication:
return "Invalid signature (response)"
case .timeout:
return "Timed out (response)"
case .missingKey:
return "Missing key (response)"
}
case .openSesame:
return "Unlocked"
case .internalError(let string):
return string
}
}
var requiresDescription: Bool {
switch self {
case .deviceNotAvailable, .messageRejected, .internalError, .responseRejected:
return true
default:
return false
}
}
var color: Color {
switch self {
case .noKeyAvailable:
return Color(red: 50/255, green: 50/255, blue: 50/255)
case .deviceNotAvailable:
return Color(red: 150/255, green: 90/255, blue: 90/255)
case .messageRejected, .responseRejected:
return Color(red: 160/255, green: 30/255, blue: 30/255)
case .internalError:
return Color(red: 100/255, green: 0/255, blue: 0/255)
case .ready:
return Color(red: 115/255, green: 140/255, blue: 90/255)
case .requestingStatus, .waitingForResponse:
return Color(red: 160/255, green: 170/255, blue: 110/255)
case .openSesame:
return Color(red: 65/255, green: 110/255, blue: 60/255)
}
}
var allowsAction: Bool {
switch self {
case .noKeyAvailable, .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)"
case .responseRejected(let cause):
switch cause {
case .invalidAuthentication:
return "Device message not authenticated"
default:
return cause.description
}
}
}
}
// MARK: Coding
extension ClientState {
var encoded: Data {
Data([code])
}
var code: UInt8 {
switch self {
case .noKeyAvailable:
return 1
case .requestingStatus:
return 2
case .deviceNotAvailable(let connectionError):
switch connectionError {
case .serverNotReached:
return 3
case .deviceDisconnected:
return 4
}
case .ready:
return 5
case .waitingForResponse:
return 6
case .messageRejected(let rejectionCause):
switch rejectionCause {
case .invalidDeviceId:
return 19
case .invalidCounter:
return 7
case .invalidTime:
return 8
case .invalidAuthentication:
return 9
case .timeout:
return 10
case .missingKey:
return 11
}
case .responseRejected(let rejectionCause):
switch rejectionCause {
case .invalidCounter:
return 12
case .invalidTime:
return 13
case .invalidAuthentication:
return 14
case .timeout:
return 15
case .missingKey:
return 16
case .invalidDeviceId:
return 20
}
case .openSesame:
return 17
case .internalError:
return 18
}
}
init(code: UInt8) {
switch code {
case 1:
self = .noKeyAvailable
case 2:
self = .requestingStatus
case 3:
self = .deviceNotAvailable(.serverNotReached)
case 4:
self = .deviceNotAvailable(.deviceDisconnected)
case 5:
self = .ready
case 6:
self = .waitingForResponse
case 7:
self = .messageRejected(.invalidCounter)
case 8:
self = .messageRejected(.invalidTime)
case 9:
self = .messageRejected(.invalidAuthentication)
case 10:
self = .messageRejected(.timeout)
case 11:
self = .messageRejected(.missingKey)
case 12:
self = .responseRejected(.invalidCounter)
case 13:
self = .responseRejected(.invalidTime)
case 14:
self = .responseRejected(.invalidAuthentication)
case 15:
self = .responseRejected(.timeout)
case 16:
self = .responseRejected(.missingKey)
case 17:
self = .openSesame
case 18:
self = .internalError("")
case 19:
self = .messageRejected(.invalidDeviceId)
case 20:
self = .responseRejected(.invalidDeviceId)
default:
self = .internalError("Unknown code \(code)")
}
}
}
extension ClientState {
@available(iOS 16, *)
var symbol: SFSymbol {
switch self {
case .deviceNotAvailable:
return .wifiExclamationmark
case .internalError:
return .applewatchSlash
case .noKeyAvailable:
return .lockTrianglebadgeExclamationmark
case .openSesame:
return .lockOpen
case .messageRejected:
return .nosign
case .responseRejected:
return .exclamationmarkTriangle
case .requestingStatus, .ready, .waitingForResponse:
return .wifiExclamationmark
}
}
}

View File

@ -0,0 +1,10 @@
import Foundation
enum ConnectionStrategy: String, CaseIterable, Identifiable {
case local = "Local"
case localFirst = "Local first"
case remote = "Remote"
case remoteFirst = "Remote first"
var id: Self { self }
}

View File

@ -0,0 +1,143 @@
import Foundation
import CBORCoding
class HistoryManagerBase: ObservableObject {
@Published
var entries: [HistoryItem] = []
}
protocol HistoryManagerProtocol: HistoryManagerBase {
var entries: [HistoryItem] { get }
func save(item: HistoryItem) throws
func delete(item: HistoryItem) -> Bool
}
final class HistoryManager: HistoryManagerBase, HistoryManagerProtocol {
private let encoder = CBOREncoder(dateEncodingStrategy: .secondsSince1970)
private var fm: FileManager {
.default
}
static var documentDirectory: URL {
try! FileManager.default.url(
for: .documentDirectory,
in: .userDomainMask,
appropriateFor: nil, create: true)
}
private let fileUrl: URL
override init() {
self.fileUrl = HistoryManager.documentDirectory.appendingPathComponent("history2.bin")
super.init()
Task {
print("Loading history...")
let all = loadEntries()
DispatchQueue.main.async {
self.entries = all
print("History loaded (\(self.entries.count) entries)")
}
}
}
private func loadEntries() -> [HistoryItem] {
guard fm.fileExists(atPath: fileUrl.path) else {
print("No history data found")
return []
}
let content: Data
do {
content = try Data(contentsOf: fileUrl)
} catch {
print("Failed to read history data: \(error)")
return []
}
let decoder = CBORDecoder()
var index = 0
var entries = [HistoryItem]()
while index < content.count {
let length = Int(content[index])
index += 1
if index + length > content.count {
print("Missing bytes in history file: needed \(length), has only \(content.count - index)")
return entries
}
let entryData = content[index..<index+length]
index += length
do {
let entry: HistoryItem = try decoder.decode(from: entryData)
entries.append(entry)
} catch {
print("Failed to decode history (index: \(index), length \(length)): \(error)")
return entries
}
}
return entries.sorted().reversed()
}
func save(item: HistoryItem) throws {
let data = try convertForStorage(item)
guard fm.fileExists(atPath: fileUrl.path) else {
try data.write(to: fileUrl)
print("First history item written (\(data[0]))")
return
}
let handle = try FileHandle(forWritingTo: fileUrl)
try handle.seekToEnd()
try handle.write(contentsOf: data)
try handle.close()
print("History item written (\(data[0]))")
}
@discardableResult
func delete(item: HistoryItem) -> Bool {
let newItems = entries
.filter { $0 != item }
let data: FlattenSequence<[Data]>
do {
data = try newItems
.map(convertForStorage)
.joined()
} catch {
print("Failed to encode items: \(error)")
return false
}
do {
try Data(data).write(to: fileUrl)
} catch {
print("Failed to save items: \(error)")
return false
}
entries = newItems
return true
}
private func convertForStorage(_ item: HistoryItem) throws -> Data {
let entryData = try encoder.encode(item)
return Data([UInt8(entryData.count)]) + entryData
}
}
final class HistoryManagerMock: HistoryManagerBase, HistoryManagerProtocol {
override init() {
super.init()
self.entries = [.mock]
}
func save(item: HistoryItem) throws {
entries.append(item)
}
func delete(item: HistoryItem) -> Bool {
entries = entries.filter { $0 != item }
return true
}
}

View File

@ -0,0 +1,193 @@
import Foundation
import CryptoKit
import SwiftUI
struct KeySet {
let remote: SymmetricKey
let device: SymmetricKey
let server: Data
}
extension KeyManagement {
enum KeyType: String, Identifiable, CaseIterable {
case deviceKey = "sesame-device"
case remoteKey = "sesame-remote"
case authToken = "sesame-remote-auth"
var id: String {
rawValue
}
var displayName: String {
switch self {
case .deviceKey:
return "Unlock Key"
case .remoteKey:
return "Response Key"
case .authToken:
return "Server Token"
}
}
var keyLength: SymmetricKeySize {
.bits256
}
var usesHashing: Bool {
switch self {
case .authToken:
return true
default:
return false
}
}
}
}
extension KeyManagement.KeyType: CustomStringConvertible {
var description: String {
displayName
}
}
private struct KeyChain {
private let keyType = kSecAttrKeyTypeEC
private let keyClass = kSecAttrKeyClassSymmetric
private let domain: String
init(domain: String) {
self.domain = domain
}
private func baseQuery(for type: KeyManagement.KeyType) -> [String : Any] {
[kSecClass as String: kSecClassInternetPassword,
kSecAttrAccount as String: type.rawValue,
kSecAttrServer as String: domain]
}
func save(_ type: KeyManagement.KeyType, _ key: SymmetricKey) {
var query = baseQuery(for: type)
query[kSecValueData as String] = key.data
let status = SecItemAdd(query as CFDictionary, nil)
guard status == errSecSuccess else {
print("Failed to store \(type): \(status)")
return
}
print("\(type) saved to keychain")
}
func load(_ type: KeyManagement.KeyType) -> SymmetricKey? {
var query = baseQuery(for: type)
query[kSecReturnData as String] = kCFBooleanTrue
var item: CFTypeRef?
let status = SecItemCopyMatching(query as CFDictionary, &item)
guard status != -25300 else {
return nil
}
guard status == errSecSuccess else {
print("Failed to get \(type): \(status)")
return nil
}
let key = item as! CFData
return SymmetricKey(data: key as Data)
}
func delete(_ type: KeyManagement.KeyType) {
let status = SecItemDelete(baseQuery(for: type) as CFDictionary)
guard status == errSecSuccess || status == errSecItemNotFound else {
print("Failed to remove \(type): \(status)")
return
}
print("\(type) removed from keychain")
}
func has(_ type: KeyManagement.KeyType) -> Bool {
load(type) != nil
}
}
final class KeyManagement: ObservableObject {
private let keyChain: KeyChain
@Published
private(set) var hasRemoteKey = false
@Published
private(set) var hasDeviceKey = false
@Published
private(set) var hasAuthToken = false
var hasAllKeys: Bool {
hasRemoteKey && hasDeviceKey && hasAuthToken
}
init() {
self.keyChain = KeyChain(domain: "christophhagen.de")
updateKeyStates()
}
func has(_ type: KeyType) -> Bool {
switch type {
case .deviceKey:
return hasDeviceKey
case .remoteKey:
return hasRemoteKey
case .authToken:
return hasAuthToken
}
}
func getAllKeys() -> KeySet? {
guard let remoteKey = get(.remoteKey),
let token = get(.authToken)?.data,
let deviceKey = get(.deviceKey) else {
return nil
}
return .init(remote: remoteKey, device: deviceKey, server: token)
}
func get(_ type: KeyType) -> SymmetricKey? {
keyChain.load(type)
}
func delete(_ type: KeyType) {
keyChain.delete(type)
updateKeyStates()
}
func generate(_ type: KeyType) {
let key = SymmetricKey(size: type.keyLength)
save(type, key: key)
}
func save(_ type: KeyType, data: Data) {
let key = SymmetricKey(data: data)
save(type, key: key)
}
private func save(_ type: KeyType, key: SymmetricKey) {
if keyChain.has(type) {
keyChain.delete(type)
}
keyChain.save(type, key)
updateKeyStates()
}
private func updateKeyStates() {
self.hasRemoteKey = keyChain.has(.remoteKey)
self.hasDeviceKey = keyChain.has(.deviceKey)
self.hasAuthToken = keyChain.has(.authToken)
}
}