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

@ -0,0 +1,18 @@
import Foundation
enum RequestType {
case challenge
case unlock
}
extension RequestType: CustomStringConvertible {
var description: String {
switch self {
case .challenge:
return "Challenge"
case .unlock:
return "Unlock"
}
}
}

View File

@ -3,91 +3,100 @@ import CryptoKit
final class Client {
// TODO: Use or delete
private let delegate = NeverCacheDelegate()
private let localRequestRoute = "message"
private let urlMessageParameter = "m"
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?) {
func send(_ message: Message, to url: String, through route: TransmissionType, using keys: KeySet) async -> ServerResponse {
let sentTime = Date.now
let signedMessage = message.authenticate(using: keys.remote)
let response: Message
switch route {
case .throughServer:
response = await send(signedMessage, toServerUrl: url, authenticateWith: keys.server, verifyUsing: keys.device)
case .overLocalWifi:
response = await send(signedMessage, toLocalDeviceUrl: url, verifyUsing: keys.device)
}
let receivedTime = Date.now
// Create best guess for creation of challenge.
let roundTripTime = receivedTime.timeIntervalSince(sentTime)
let serverChallenge = ServerChallenge(
creationDate: sentTime.addingTimeInterval(roundTripTime / 2),
message: response)
// Validate message content
guard response.result == .messageAccepted else {
print("Failure: \(response)")
return (response, nil)
}
guard response.clientChallenge == message.clientChallenge else {
print("Invalid client challenge: \(response)")
return (response.with(result: .invalidClientChallengeFromDevice), nil)
}
return (response, serverChallenge)
}
private func send(_ message: SignedMessage, toLocalDeviceUrl server: String, verifyUsing deviceKey: SymmetricKey) async -> Message {
let data = message.encoded.hexEncoded
guard let url = URL(string: server + "message?m=\(data)") else {
return (.internalError("Invalid server url"), nil)
guard let url = URL(string: server)?.appendingPathComponent("\(localRequestRoute)?\(urlMessageParameter)=\(data)") else {
return message.message.with(result: .serverUrlInvalid)
}
var request = URLRequest(url: url)
request.httpMethod = "POST"
return await requestAndDecode(request)
request.timeoutInterval = 10
return await perform(request, inResponseTo: message.message, verifyUsing: deviceKey)
}
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)
private func send(_ message: SignedMessage, toServerUrl server: String, authenticateWith authToken: Data, verifyUsing deviceKey: SymmetricKey) async -> Message {
guard let url = URL(string: server)?.appendingPathComponent(SesameRoute.postMessage.rawValue) else {
return message.message.with(result: .serverUrlInvalid)
}
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.httpBody = message.encoded
request.httpMethod = "POST"
request.timeoutInterval = 10
return await requestAndDecode(request)
request.addValue(authToken.hexEncoded, forHTTPHeaderField: SesameHeader.authenticationHeader)
return await perform(request, inResponseTo: message.message, verifyUsing: deviceKey)
}
private func requestAndDecode(_ request: URLRequest) async -> (state: ClientState, response: Message?) {
guard let data = await fulfill(request) else {
return (.deviceNotAvailable(.serverNotReached), nil)
private func perform(_ request: URLRequest, inResponseTo message: Message, verifyUsing deviceKey: SymmetricKey) async -> Message {
let (response, responseData) = await fulfill(request)
guard response == .messageAccepted, let data = responseData else {
return message.with(result: response)
}
guard let byte = data.first else {
return (.internalError("Empty response"), nil)
guard data.count == SignedMessage.size else {
print("[WARN] Received message with \(data.count) bytes (\(Array(data)))")
return message.with(result: .invalidMessageSizeFromDevice)
}
guard let status = MessageResult(rawValue: byte) else {
return (.internalError("Invalid message response: \(byte)"), nil)
let decodedMessage: SignedMessage
do {
decodedMessage = try SignedMessage(decodeFrom: data)
} catch {
return message.with(result: error as! MessageResult)
}
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)
guard decodedMessage.isValid(using: deviceKey) else {
return message.with(result: .invalidSignatureFromDevice)
}
let messageData = Array(data.advanced(by: 1))
let message = Message(decodeFrom: messageData)
return (result, message)
return decodedMessage.message
}
private func fulfill(_ request: URLRequest) async -> Data? {
private func fulfill(_ request: URLRequest) async -> (response: MessageResult, data: 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
return (.unexpectedUrlResponseType, nil)
}
guard code == 200 else {
print("Invalid server response \(code)")
return nil
}
return data
return (.init(httpCode: code), data)
} catch {
print("Request failed: \(error)")
return nil
return (.deviceTimedOut, nil)
}
}
}
class NeverCacheDelegate: NSObject, NSURLConnectionDataDelegate {
func connection(_ connection: NSURLConnection, willCacheResponse cachedResponse: CachedURLResponse) -> CachedURLResponse? {
return nil
}
}

View File

@ -1,371 +0,0 @@
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

@ -1,10 +1,27 @@
import Foundation
import CryptoKit
enum ConnectionStrategy: String, CaseIterable, Identifiable {
case local = "Local"
case localFirst = "Local first"
case remote = "Remote"
case remoteFirst = "Remote first"
enum ConnectionStrategy: Int, CaseIterable, Identifiable {
case local = 0
case remote = 1
case localFirst = 2
case remoteFirst = 3
var id: Self { self }
var id: Int { rawValue }
var transmissionTypes: [TransmissionType] {
switch self {
case .local: return [.overLocalWifi]
case .localFirst: return [.overLocalWifi, .throughServer]
case .remote: return [.throughServer]
case .remoteFirst: return [.throughServer, .overLocalWifi]
}
}
}
extension ConnectionStrategy: CustomStringConvertible {
var description: String {
transmissionTypes.map { $0.displayName }.joined(separator: "+")
}
}

View File

@ -0,0 +1,14 @@
import Foundation
extension Array {
func count(where closure: (Element) -> Bool) -> Int {
var result = 0
forEach { element in
if closure(element) {
result += 1
}
}
return result
}
}

View File

@ -0,0 +1,71 @@
import Foundation
import CryptoKit
extension SymmetricKey {
var data: Data {
withUnsafeBytes { Data(Array($0)) }
}
var base64: String {
data.base64EncodedString()
}
var displayString: String {
data.hexEncoded.uppercased().split(by: 4).joined(separator: " ")
}
var codeString: String {
" {" +
withUnsafeBytes {
return Data(Array($0))
}.map(String.init).joined(separator: ", ") +
"},"
}
}
extension SHA256.Digest {
var hexEncoded: String {
Data(map { $0 }).hexEncoded
}
}
extension String {
func split(by length: Int) -> [String] {
var startIndex = self.startIndex
var results = [Substring]()
while startIndex < self.endIndex {
let endIndex = self.index(startIndex, offsetBy: length, limitedBy: self.endIndex) ?? self.endIndex
results.append(self[startIndex..<endIndex])
startIndex = endIndex
}
return results.map { String($0) }
}
}
let protocolSalt = "CryptoKit Playgrounds Putting It Together".data(using: .utf8)!
/// Generates an ephemeral key agreement key and performs key agreement to get the shared secret and derive the symmetric encryption key.
func encrypt(_ data: Data, to theirEncryptionKey: Curve25519.KeyAgreement.PublicKey, signedBy ourSigningKey: Curve25519.Signing.PrivateKey) throws ->
(ephemeralPublicKeyData: Data, ciphertext: Data, signature: Data) {
let ephemeralKey = Curve25519.KeyAgreement.PrivateKey()
let ephemeralPublicKey = ephemeralKey.publicKey.rawRepresentation
let sharedSecret = try ephemeralKey.sharedSecretFromKeyAgreement(with: theirEncryptionKey)
let symmetricKey = sharedSecret.hkdfDerivedSymmetricKey(using: SHA256.self,
salt: protocolSalt,
sharedInfo: ephemeralPublicKey +
theirEncryptionKey.rawRepresentation +
ourSigningKey.publicKey.rawRepresentation,
outputByteCount: 32)
let ciphertext = try ChaChaPoly.seal(data, using: symmetricKey).combined
let signature = try ourSigningKey.signature(for: ciphertext + ephemeralPublicKey + theirEncryptionKey.rawRepresentation)
return (ephemeralPublicKey, ciphertext, signature)
}

View File

@ -0,0 +1,9 @@
import Foundation
import SwiftUI
extension Text {
init(display: CustomStringConvertible) {
self.init(display.description)
}
}

View File

@ -0,0 +1,8 @@
import Foundation
extension UInt32 {
static func random() -> UInt32 {
random(in: UInt32.min...UInt32.max)
}
}

View File

@ -1,6 +1,7 @@
import Foundation
import CBORCoding
/*
class HistoryManagerBase: ObservableObject {
@Published
@ -141,3 +142,4 @@ final class HistoryManagerMock: HistoryManagerBase, HistoryManagerProtocol {
return true
}
}
*/

View File

@ -129,9 +129,8 @@ final class KeyManagement: ObservableObject {
@Published
private(set) var hasAuthToken = false
var hasAllKeys: Bool {
hasRemoteKey && hasDeviceKey && hasAuthToken
}
@Published
private(set) var hasAllKeys = false
init() {
self.keyChain = KeyChain(domain: "christophhagen.de")
@ -189,5 +188,6 @@ final class KeyManagement: ObservableObject {
self.hasRemoteKey = keyChain.has(.remoteKey)
self.hasDeviceKey = keyChain.has(.deviceKey)
self.hasAuthToken = keyChain.has(.authToken)
self.hasAllKeys = hasRemoteKey && hasDeviceKey && hasAuthToken
}
}

View File

@ -0,0 +1,12 @@
import Foundation
struct PendingOperation {
let route: TransmissionType
let operation: RequestType
}
extension PendingOperation: Equatable {
}

View File

@ -0,0 +1,229 @@
import Foundation
import SwiftUI
import SwiftData
final class RequestCoordinator: ObservableObject {
@Published
var serverChallenge: ServerChallenge? = nil
@Published
var state: MessageResult = .noKeyAvailable
@Published
private var timer: Timer?
@Published
var pendingRequests: [PendingOperation] = []
@Published
var activeRequest: PendingOperation?
@Published
var keyManager = KeyManagement()
@AppStorage("server")
var serverPath: String = "https://christophhagen.de/sesame/"
@AppStorage("localIP")
var localAddress: String = "192.168.178.104/"
@AppStorage("connectionType")
var connectionType: ConnectionStrategy = .remoteFirst
let modelContext: ModelContext
init(modelContext: ModelContext) {
self.modelContext = modelContext
if keyManager.hasAllKeys {
self.state = .notChecked
}
}
let client = Client()
var needsNewServerChallenge: Bool {
serverChallenge?.isExpired ?? true
}
@Published
var isPerformingRequest: Bool = false
func startUnlock() {
addOperations(.challenge, .unlock)
}
func startChallenge() {
addOperations(.challenge)
}
private func addOperations(_ operations: RequestType...) {
#warning("Only perform challenge when doing unlock? Remove code complexity")
// Just add all operations for an unlock
// For every completed operation, the unnecessary ones will be removed without executing them
let operations = connectionType.transmissionTypes.map { route in
operations.map { PendingOperation(route: route, operation: $0) }
}.joined()
pendingRequests.append(contentsOf: operations)
continueRequests()
}
private func continueRequests() {
guard activeRequest == nil else {
return
}
guard !pendingRequests.isEmpty else {
self.isPerformingRequest = false
return
}
let activeRequest = pendingRequests.removeFirst()
self.activeRequest = activeRequest
self.isPerformingRequest = true
Task {
await process(request: activeRequest)
}
}
private func process(request: PendingOperation) async {
let startTime = Date.now
let (success, response, challenge) = await self.start(request)
let endTime = Date.now
let roundTripTime = endTime.timeIntervalSince(startTime)
if let s = challenge?.message {
print("\(s) took \(Int(roundTripTime * 1000)) ms")
} else {
print("\(request.operation.description) took \(Int(roundTripTime * 1000)) ms")
}
if request.operation == .unlock, let response {
print("Saving history item")
let item = HistoryItem(message: response, startDate: startTime, route: request.route, finishDate: endTime)
modelContext.insert(item)
}
DispatchQueue.main.async {
self.filterPendingRequests(after: request, success: success, hasChallenge: challenge != nil)
if let response {
self.state = response.result
}
if let challenge {
self.serverChallenge = challenge
}
self.activeRequest = nil
self.continueRequests()
}
}
private func filterPendingRequests(after operation: PendingOperation, success: Bool, hasChallenge: Bool) {
if success {
// Filter all unlocks
if operation.operation == .unlock {
// Successful unlock means no need for next challenge or unlocks, so remove all
self.pendingRequests = []
} else {
// Successful challenge means no need for additional challenges, but keep unlocks
self.pendingRequests = pendingRequests.filter { $0.operation != .challenge }
}
} else {
// Filter all operations with the same route for connection errors
// And with type, depending on error?
}
}
private func start(_ operation: PendingOperation) async -> OptionalServerResponse {
switch operation.operation {
case .challenge:
if let serverChallenge, !serverChallenge.isExpired {
return (true, serverChallenge.message, serverChallenge)
}
return await performChallenge(route: operation.route)
case .unlock:
guard let serverChallenge, !serverChallenge.isExpired else {
return (false, nil, nil)
}
return await performUnlock(with: serverChallenge.message, route: operation.route)
}
}
private func performChallenge(route: TransmissionType) async -> OptionalServerResponse {
let initialMessage = Message.initial()
let (result, challenge) = await send(initialMessage, route: route)
guard let message = challenge?.message else {
return (false, result, nil)
}
// Can't get here without the message being accepted
guard message.messageType == .challenge else {
print("Invalid message type for challenge: \(message)")
return (false, result.with(result: .invalidMessageTypeFromDevice), nil)
}
return (true, result.with(result: .deviceAvailable), challenge)
}
private func performUnlock(with challenge: Message, route: TransmissionType) async -> OptionalServerResponse {
let request = challenge.requestMessage()
let (unlockState, responseData) = await send(request, route: route)
guard let response = responseData?.message else {
return (false, unlockState, nil)
}
switch response.messageType {
case .initial, .request:
print("Invalid message type for response: \(response)")
return (false, response.with(result: .invalidMessageTypeFromDevice), nil)
case .challenge:
// New challenge received, challenge was expired
return (true, unlockState, responseData)
case .response:
break
}
guard response.serverChallenge == request.serverChallenge else {
print("Invalid server challenge for unlock: \(response)")
return (false, response.with(result: .invalidServerChallengeFromDevice), nil)
}
return (true, response.with(result: .unlocked), nil)
}
private func url(for route: TransmissionType) -> String {
switch route {
case .throughServer:
return serverPath
case .overLocalWifi:
return localAddress
}
}
private func send(_ message: Message, route: TransmissionType) async -> ServerResponse {
guard let keys = keyManager.getAllKeys() else {
return (message.with(result: .noKeyAvailable), nil)
}
let url = url(for: route)
return await client.send(message, to: url, through: route, using: keys)
}
func startUpdatingServerChallenge() {
guard timer == nil else {
return
}
DispatchQueue.main.async {
self.timer = Timer.scheduledTimer(withTimeInterval: 3, repeats: true) { [weak self] timer in
guard let self else {
timer.invalidate()
return
}
DispatchQueue.main.async {
self.startChallenge()
}
}
self.timer!.fire()
}
}
func endUpdatingServerChallenge() {
timer?.invalidate()
timer = nil
}
}

View File

@ -0,0 +1,17 @@
import Foundation
struct ServerChallenge {
private static let challengeExpiryTime: TimeInterval = 25.0
let creationDate: Date
let message: Message
var isExpired: Bool {
creationDate.addingTimeInterval(ServerChallenge.challengeExpiryTime) < Date.now
}
}
typealias ServerResponse = (result: Message, challenge: ServerChallenge?)
typealias OptionalServerResponse = (success: Bool, result: Message?, challenge: ServerChallenge?)

View File

@ -0,0 +1,42 @@
import Foundation
import SFSafeSymbols
enum TransmissionType: Int {
case throughServer = 0
case overLocalWifi = 1
}
extension TransmissionType: Codable {
}
extension TransmissionType {
var symbol: SFSymbol {
switch self {
case .throughServer: return .network
case .overLocalWifi: return .wifi
}
}
}
extension TransmissionType: CaseIterable {
}
extension TransmissionType: CustomStringConvertible {
var description: String {
displayName
}
}
extension TransmissionType {
var displayName: String {
switch self {
case .throughServer: return "Mobile"
case .overLocalWifi: return "WiFi"
}
}
}