Challenge-response, SwiftData, new UI

This commit is contained in:
Christoph Hagen
2023-12-12 17:33:42 +01:00
parent 7a443d51b3
commit 941aebd9ca
51 changed files with 1741 additions and 1674 deletions

View File

@ -1,4 +1,5 @@
import SwiftUI
import SwiftData
import SFSafeSymbols
import CryptoKit
@ -7,73 +8,20 @@ struct ContentView: View {
@Binding
var didLaunchFromComplication: Bool
@AppStorage("connectionType")
var connectionType: ConnectionStrategy = .remoteFirst
@ObservedObject
var coordinator: RequestCoordinator
@AppStorage("server")
var serverPath: String = "https://christophhagen.de/sesame/"
@AppStorage("localIP")
var localAddress: String = "192.168.178.104/"
@AppStorage("counter")
var nextMessageCounter: Int = 0
@AppStorage("compensate")
var isCompensatingDaylightTime: Bool = false
@AppStorage("deviceId")
private var deviceId: Int = 0
@EnvironmentObject
var keyManager: KeyManagement
@EnvironmentObject
var history: HistoryManager
@State
var state: ClientState = .noKeyAvailable
@State
var stateResetTimer: Timer?
let server = Client()
private var firstTryIsLocalConnection: Bool {
switch connectionType {
case .local, .localFirst:
return true
case .remote, .remoteFirst:
return false
}
}
private var hasSecondTry: Bool {
switch connectionType {
case .localFirst, .remoteFirst:
return true
default:
return false
}
}
private var secondTryIsLocalConnection: Bool {
switch connectionType {
case .local, .localFirst:
return false
case .remote, .remoteFirst:
return true
}
init(coordinator: RequestCoordinator, didLaunchFromComplication: Binding<Bool>) {
self._didLaunchFromComplication = didLaunchFromComplication
self.coordinator = coordinator
}
var buttonBackground: Color {
state.allowsAction ?
.white.opacity(0.2) :
.black.opacity(0.2)
.white.opacity(0.2)
}
var buttonColor: Color {
state.allowsAction ? .white : .gray
.white
}
var body: some View {
@ -85,145 +33,43 @@ struct ContentView: View {
.aspectRatio(contentMode: .fit)
.fontWeight(.ultraLight)
.padding()
.onTapGesture(perform: mainButtonPressed)
.disabled(!state.allowsAction)
if state == .waitingForResponse {
.onTapGesture(perform: coordinator.startUnlock)
if coordinator.isPerformingRequest {
ProgressView()
.progressViewStyle(CircularProgressViewStyle())
.frame(width: 20, height: 20)
} else {
Text(state.actionText)
Text("Unlock")
.font(.subheadline)
}
}
Spacer()
}
.background(state.color)
.animation(.easeInOut, value: state.color)
.onAppear {
if state == .noKeyAvailable, keyManager.hasAllKeys {
state = .ready
}
}
.onChange(of: didLaunchFromComplication) { launched in
.background(coordinator.state.color)
.animation(.easeInOut, value: coordinator.state.color)
.onChange(of: didLaunchFromComplication) { _, launched in
guard launched else {
return
}
didLaunchFromComplication = false
mainButtonPressed()
}
}
func mainButtonPressed() {
guard let keys = keyManager.getAllKeys(),
let deviceId = UInt8(exactly: deviceId) else {
return
}
sendMessage(from: deviceId, using: keys, isFirstTry: true)
}
private func sendMessage(from deviceId: UInt8, using keys: KeySet, isFirstTry: Bool) {
preventStateReset()
state = .waitingForResponse
let localConnection = isFirstTry ? firstTryIsLocalConnection : secondTryIsLocalConnection
Task {
let response = await send(
count: UInt32(nextMessageCounter),
from: deviceId,
using: keys,
to: server,
over: localConnection,
while: isCompensatingDaylightTime,
localAddress: localAddress,
remoteAddress: serverPath)
DispatchQueue.main.async {
state = response.response
scheduleStateReset()
if let counter = response.responseMessage?.id {
nextMessageCounter = Int(counter)
}
}
save(historyItem: response)
guard isFirstTry, hasSecondTry else {
return
}
DispatchQueue.main.async {
sendMessage(from: deviceId, using: keys, isFirstTry: false)
}
}
}
private func preventStateReset() {
stateResetTimer?.invalidate()
stateResetTimer = nil
}
private func scheduleStateReset() {
stateResetTimer?.invalidate()
stateResetTimer = Timer.scheduledTimer(withTimeInterval: 8.0, repeats: false) { _ in
DispatchQueue.main.async {
resetState()
}
}
}
private func resetState() {
state = keyManager.hasAllKeys ? .ready : .noKeyAvailable
preventStateReset()
}
private func save(historyItem: HistoryItem) {
do {
try history.save(item: historyItem)
} catch {
print("Failed to save item: \(error)")
coordinator.startUnlock()
}
}
}
private func send(count: UInt32, from deviceId: UInt8, using keys: KeySet, to server: Client, over localConnection: Bool, while compensatingTime: Bool, localAddress: String, remoteAddress: String) async -> HistoryItem {
let sentTime = Date()
// Add time to compensate that the device is using daylight savings time
let timeCompensation: UInt32 = compensatingTime ? 3600 : 0
let content = Message.Content(
time: sentTime.timestamp + timeCompensation,
id: count,
device: deviceId)
let message = content.authenticate(using: keys.remote)
print("Sending message \(count)")
let address = localConnection ? localAddress : remoteAddress
let (newState, responseMessage) = await send(message, to: server, using: keys.server, local: localConnection, address: address)
var historyItem = HistoryItem(
sent: message.content,
sentDate: sentTime,
local: localConnection,
response: newState,
responseDate: .now,
responseMessage: responseMessage?.content)
guard let responseMessage else {
return historyItem
}
guard responseMessage.isValid(using: keys.device) else {
historyItem.response = .responseRejected(.invalidAuthentication)
return historyItem
}
return historyItem
}
private func send(_ message: Message, to server: Client, using authToken: Data, local: Bool, address: String) async -> (state: ClientState, response: Message?) {
if local {
return await server.sendMessageOverLocalNetwork(message, server: address)
} else {
return await server.send(message, server: address, authToken: authToken)
#Preview {
do {
let config = ModelConfiguration(isStoredInMemoryOnly: true)
let container = try ModelContainer(for: HistoryItem.self, configurations: config)
let item = HistoryItem.mock
container.mainContext.insert(item)
try container.mainContext.save()
let coordinator = RequestCoordinator(modelContext: container.mainContext)
return ContentView(coordinator: coordinator, didLaunchFromComplication: .constant(false))
.modelContainer(container)
} catch {
fatalError("Failed to create model container.")
}
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView(didLaunchFromComplication: .constant(false))
.environmentObject(KeyManagement())
.environmentObject(HistoryManager())
}
}

View File

@ -1,4 +1,5 @@
import SwiftUI
import SwiftData
import SFSafeSymbols
private let df: DateFormatter = {
@ -10,27 +11,16 @@ private let df: DateFormatter = {
}()
struct HistoryItemDetail: View {
@Environment(\.modelContext)
private var modelContext
let item: HistoryItem
let history: HistoryManagerProtocol
@Environment(\.dismiss) private var dismiss
private var entryTime: String {
df.string(from: item.requestDate)
}
var counterText: String {
let sentCounter = item.request.id
let startText = "\(sentCounter)"
guard let rCounter = item.responseMessage?.id else {
return startText
}
guard sentCounter + 1 != rCounter && sentCounter != rCounter else {
return startText
}
return "\(sentCounter) -> \(rCounter)"
df.string(from: item.startDate)
}
var body: some View {
@ -43,21 +33,16 @@ struct HistoryItemDetail: View {
value: entryTime)
SettingsListTextItem(
title: "Connection",
value: item.usedLocalConnection ? "Local" : "Remote")
SettingsListTextItem(
title: "Device ID",
value: "\(item.request.deviceId!)")
SettingsListTextItem(
title: "Message Counter",
value: counterText)
value: item.route.displayName)
SettingsListTextItem(
title: "Round Trip Time",
value: "\(Int(item.roundTripTime * 1000)) ms")
if let offset = item.clockOffset {
SettingsListTextItem(
title: "Clock offset",
value: "\(offset) seconds")
}
SettingsListTextItem(
title: "Client challenge",
value: "\(item.message.clientChallenge)")
SettingsListTextItem(
title: "Server challenge",
value: "\(item.message.serverChallenge)")
Button {
delete(item: item)
} label: {
@ -76,15 +61,22 @@ struct HistoryItemDetail: View {
}
private func delete(item: HistoryItem) {
guard history.delete(item: item) else {
return
}
modelContext.delete(item)
dismiss()
}
}
struct HistoryItemDetail_Previews: PreviewProvider {
static var previews: some View {
HistoryItemDetail(item: .mock, history: HistoryManagerMock())
#Preview {
do {
let config = ModelConfiguration(isStoredInMemoryOnly: true)
let container = try ModelContainer(for: HistoryItem.self, configurations: config)
let item = HistoryItem.mock
container.mainContext.insert(item)
try container.mainContext.save()
return HistoryItemDetail(item: .mock)
.modelContainer(container)
} catch {
fatalError("Failed to create model container.")
}
}

View File

@ -14,7 +14,7 @@ struct HistoryListRow: View {
let item: HistoryItem
private var entryTime: String {
df.string(from: item.requestDate)
df.string(from: item.startDate)
}
var body: some View {

View File

@ -1,21 +1,23 @@
import SwiftUI
import SwiftData
struct HistoryView: View {
@Environment(\.modelContext)
private var modelContext
@ObservedObject
var history: HistoryManager
@Query(sort: \HistoryItem.startDate, order: .reverse)
var history: [HistoryItem] = []
private var unlockCount: Int {
history.entries.count {
$0.response == .openSesame
}
history.count { $0.response == .unlocked }
}
private var percentage: Double {
guard history.entries.count > 0 else {
guard history.count > 0 else {
return 0
}
return Double(unlockCount * 100) / Double(history.entries.count)
return Double(unlockCount * 100) / Double(history.count)
}
var body: some View {
@ -23,7 +25,7 @@ struct HistoryView: View {
List {
HStack {
VStack(alignment: .leading) {
Text("\(history.entries.count) requests")
Text("\(history.count) requests")
.foregroundColor(.primary)
.font(.body)
Text(String(format: "%.1f %% success", percentage))
@ -34,9 +36,9 @@ struct HistoryView: View {
}
.listRowBackground(Color.clear)
ForEach(history.entries) { item in
ForEach(history) { item in
NavigationLink {
HistoryItemDetail(item: item, history: history)
HistoryItemDetail(item: item)
} label: {
HistoryListRow(item: item)
}
@ -54,14 +56,21 @@ struct HistoryView: View {
}
private func delete(item: HistoryItem) {
guard history.delete(item: item) else {
return
}
modelContext.delete(item)
}
}
struct HistoryView_Previews: PreviewProvider {
static var previews: some View {
HistoryView(history: HistoryManager())
#Preview {
do {
let config = ModelConfiguration(isStoredInMemoryOnly: true)
let container = try ModelContainer(for: HistoryItem.self, configurations: config)
let item = HistoryItem.mock
container.mainContext.insert(item)
try container.mainContext.save()
return HistoryView()
.modelContainer(container)
} catch {
fatalError("Failed to create model container.")
}
}

View File

@ -1,29 +1,42 @@
import SwiftUI
import SwiftData
@main
struct Sesame_Watch_Watch_AppApp: App {
@State
var modelContainer: ModelContainer
@ObservedObject
var coordinator: RequestCoordinator
let keyManagement = KeyManagement()
let history = HistoryManager()
@State
var selected: Int = 0
@State
var didLaunchFromComplication = false
init() {
do {
let modelContainer = try ModelContainer(for: HistoryItem.self)
self.modelContainer = modelContainer
self.coordinator = .init(modelContext: modelContainer.mainContext)
} catch {
fatalError("Failed to create model container: \(error)")
}
}
var body: some Scene {
WindowGroup {
TabView(selection: $selected) {
ContentView(didLaunchFromComplication: $didLaunchFromComplication)
.environmentObject(keyManagement)
.environmentObject(history)
ContentView(coordinator: coordinator, didLaunchFromComplication: $didLaunchFromComplication)
.tag(1)
SettingsView()
.environmentObject(keyManagement)
.tag(2)
HistoryView(history: history)
HistoryView()
.tag(3)
}
.tabViewStyle(PageTabViewStyle())
@ -32,5 +45,6 @@ struct Sesame_Watch_Watch_AppApp: App {
didLaunchFromComplication = true
}
}
.modelContainer(modelContainer)
}
}

View File

@ -1,28 +0,0 @@
import SwiftUI
struct SettingsListToggleItem: View {
let title: String
@Binding
var value: Bool
let subtitle: String
var body: some View {
VStack(alignment: .leading) {
Toggle(title, isOn: $value)
Text(subtitle)
.font(.footnote)
.foregroundColor(.secondary)
}
.padding()
.cornerRadius(8)
}
}
struct SettingsListToggleItem_Previews: PreviewProvider {
static var previews: some View {
SettingsListToggleItem(title: "Toggle", value: .constant(true), subtitle: "Some longer text explaining what the toggle does")
}
}

View File

@ -11,29 +11,22 @@ struct SettingsView: View {
@AppStorage("localIP")
var localAddress: String = "192.168.178.104/"
@AppStorage("counter")
var nextMessageCounter: Int = 0
@AppStorage("compensate")
var isCompensatingDaylightTime: Bool = false
@AppStorage("deviceId")
private var deviceId: Int = 0
@EnvironmentObject
var keys: KeyManagement
var some: String { "some" }
var body: some View {
NavigationStack {
List {
Picker("Connection", selection: $connectionType) {
Text(ConnectionStrategy.local.rawValue)
Text(display: ConnectionStrategy.local)
.tag(ConnectionStrategy.local)
Text(ConnectionStrategy.localFirst.rawValue)
Text(display: ConnectionStrategy.localFirst)
.tag(ConnectionStrategy.localFirst)
Text(ConnectionStrategy.remote.rawValue)
Text(display: ConnectionStrategy.remote)
.tag(ConnectionStrategy.remote)
Text(ConnectionStrategy.remoteFirst.rawValue)
Text(display: ConnectionStrategy.remoteFirst)
.tag(ConnectionStrategy.remoteFirst)
}
.padding(.leading)
@ -45,18 +38,6 @@ struct SettingsView: View {
title: "Local url",
value: $localAddress,
footnote: "The url where the device can be reached directly on the local WiFi network.")
SettingsNumberItemLink(
title: "Device ID",
value: $deviceId,
footnote: "The device ID is unique for each remote device, and is assigned by the system administrator.")
SettingsNumberItemLink(
title: "Message counter",
value: $nextMessageCounter,
footnote: "The message counter is increased after every message to the device, and used to prevent replay attacks.")
SettingsListToggleItem(
title: "Daylight savings",
value: $isCompensatingDaylightTime,
subtitle: "Compensate timestamps if the remote has daylight savings time wrongly set.")
SettingsKeyItemLink(
type: .deviceKey,
footnote: "Some text describing the purpose of the key.")