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,26 @@
import Foundation
import CryptoKit
extension Message {
/**
Calculate an authentication code for the message content.
- Parameter key: The key to use to sign the content.
- Returns: The new message signed with the key.
*/
func authenticate(using key: SymmetricKey) -> SignedMessage {
let mac = HMAC<SHA256>.authenticationCode(for: encoded, using: key)
return .init(mac: Data(mac.map { $0 }), message: self)
}
/**
Calculate an authentication code for the message content and convert everything to data.
- Parameter key: The key to use to sign the content.
- Returns: The new message signed with the key, serialized to bytes.
*/
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
}
}

View File

@ -0,0 +1,123 @@
import Foundation
/**
The message content without authentication.
*/
struct Message: Equatable, Hashable {
/// The type of message being sent.
let messageType: MessageType
/**
* The random nonce created by the remote
*
* This nonce is a random number created by the remote, different for each unlock request.
* It is set for all message types.
*/
let clientChallenge: UInt32
/**
* A random number to sign by the remote
*
* This nonce is set by the server after receiving an initial message.
* It is set for the message types `challenge`, `request`, and `response`.
*/
let serverChallenge: UInt32
/**
* The response status for the previous message.
*
* It is set only for messages from the server, e.g. the `challenge` and `response` message types.
* Must be set to `MessageAccepted` for other messages.
*/
let result: MessageResult
init(messageType: MessageType, clientChallenge: UInt32, serverChallenge: UInt32, result: MessageResult) {
self.messageType = messageType
self.clientChallenge = clientChallenge
self.serverChallenge = serverChallenge
self.result = result
}
/**
Decode message content from data.
The data consists of two `UInt32` encoded in little endian format
- Warning: The sequence must contain at least 8 bytes, or the function will crash.
- Parameter data: The sequence containing the bytes.
*/
init(decodeFrom data: Data) throws {
guard data.count == Message.size else {
print("Invalid message size \(data.count)")
throw MessageResult.invalidMessageSizeFromDevice
}
guard let messageType = MessageType(rawValue: data.first!) else {
print("Invalid message type \(data.first!)")
throw MessageResult.invalidMessageTypeFromDevice
}
self.messageType = messageType
self.clientChallenge = UInt32(data: data.dropFirst().prefix(UInt32.byteSize))
self.serverChallenge = UInt32(data: data.dropFirst(UInt32.byteSize+1).prefix(UInt32.byteSize))
guard let result = MessageResult(rawValue: data.last!) else {
print("Invalid message result \(data.last!)")
throw MessageResult.unknownMessageResultFromDevice
}
self.result = result
}
/// The message content encoded to data
var encoded: Data {
messageType.encoded + clientChallenge.encoded + serverChallenge.encoded + result.encoded
}
}
extension Message: Codable {
enum CodingKeys: Int, CodingKey {
case messageType = 1
case clientChallenge = 2
case serverChallenge = 3
case result = 4
}
}
extension Message {
init(error: MessageResult, type: MessageType) {
self.init(messageType: type, clientChallenge: 0, serverChallenge: 0, result: error)
}
static func initial() -> Message {
.init(
messageType: .initial,
clientChallenge: .random(),
serverChallenge: 0,
result: .messageAccepted)
}
func with(result: MessageResult) -> Message {
.init(
messageType: messageType.responseType,
clientChallenge: clientChallenge,
serverChallenge: serverChallenge,
result: result)
}
/**
Create the message to respond to this challenge
*/
func requestMessage() -> Message {
.init(
messageType: .request,
clientChallenge: clientChallenge,
serverChallenge: serverChallenge,
result: .messageAccepted)
}
}
extension Message: CustomStringConvertible {
var description: String {
"\(messageType)(\(clientChallenge)->\(serverChallenge), \(result))"
}
}

View File

@ -0,0 +1,118 @@
import Foundation
import SwiftUI
import SFSafeSymbols
extension MessageResult {
var color: Color {
switch self {
// Initial state when not configured
case .noKeyAvailable:
return Color(red: 50/255, green: 50/255, blue: 50/255)
// All ready states
case .notChecked,
.messageAccepted,
.deviceAvailable:
return Color(red: 115/255, green: 140/255, blue: 90/255)
case .unlocked:
return Color(red: 65/255, green: 110/255, blue: 60/255)
// All implementation errors
case .textReceived,
.unexpectedSocketEvent,
.invalidMessageSizeFromDevice,
.invalidMessageSizeFromRemote,
.invalidMessageTypeFromDevice,
.invalidMessageTypeFromRemote,
.unknownMessageResultFromDevice,
.invalidUrlParameter,
.noOrInvalidBodyDataFromRemote,
.invalidMessageResultFromRemote,
.unexpectedUrlResponseType,
.unexpectedServerResponseCode,
.internalServerError,
.pathOnServerNotFound,
.missingOrInvalidAuthenticationHeaderFromRemote:
return Color(red: 30/255, green: 30/255, blue: 160/255)
// All security errors
case .invalidSignatureFromRemote,
.invalidServerChallengeFromDevice,
.invalidServerChallengeFromRemote,
.invalidClientChallengeFromDevice,
.invalidClientChallengeFromRemote,
.invalidSignatureFromDevice:
return Color(red: 160/255, green: 30/255, blue: 30/255)
// Connection errors
case .tooManyRequests,
.deviceTimedOut,
.deviceNotConnected,
.serviceBehindProxyUnavailable:
return Color(red: 150/255, green: 90/255, blue: 90/255)
// Configuration errors
case .serverUrlInvalid, .invalidServerAuthenticationFromRemote:
return Color(red: 100/255, green: 100/255, blue: 140/255)
}
}
var symbol: SFSymbol {
switch self {
// Initial state when not configured
case .noKeyAvailable:
return .questionmarkKeyFilled // .keySlash in 5.0
// All ready states
case .notChecked,
.messageAccepted,
.deviceAvailable:
return .checkmark
case .unlocked:
return .lockOpen
// All implementation errors
case .textReceived,
.unexpectedSocketEvent,
.invalidMessageSizeFromDevice,
.invalidMessageSizeFromRemote,
.invalidMessageTypeFromDevice,
.invalidMessageTypeFromRemote,
.unknownMessageResultFromDevice,
.invalidUrlParameter,
.noOrInvalidBodyDataFromRemote,
.invalidMessageResultFromRemote,
.unexpectedUrlResponseType,
.unexpectedServerResponseCode,
.internalServerError,
.pathOnServerNotFound,
.missingOrInvalidAuthenticationHeaderFromRemote:
return .questionmarkDiamond
// All security errors
case .invalidSignatureFromRemote,
.invalidServerChallengeFromDevice,
.invalidServerChallengeFromRemote,
.invalidClientChallengeFromDevice,
.invalidClientChallengeFromRemote,
.invalidSignatureFromDevice:
return .lockTrianglebadgeExclamationmark
// Connection errors
case .tooManyRequests,
.deviceTimedOut,
.deviceNotConnected,
.serviceBehindProxyUnavailable:
return .antennaRadiowavesLeftAndRightSlash
// Configuration errors
case .serverUrlInvalid, .invalidServerAuthenticationFromRemote:
return .gearBadgeQuestionmark
}
}
}

View File

@ -0,0 +1,57 @@
import Foundation
enum MessageType: UInt8 {
/// The initial message from remote to device to request a challenge.
case initial = 0
/// The second message in an unlock with the challenge from the device to the remote
case challenge = 1
/// The third message with the signed challenge from the remote to the device
case request = 2
/// The final message with the unlock result from the device to the remote
case response = 3
}
extension MessageType {
var encoded: Data {
Data([rawValue])
}
}
extension MessageType: Codable {
}
extension MessageType {
var responseType: MessageType {
switch self {
case .initial:
return .challenge
case .challenge:
return .request
case .request, .response:
return .response
}
}
}
extension MessageType: CustomStringConvertible {
var description: String {
switch self {
case .initial:
return "Initial"
case .challenge:
return "Challenge"
case .request:
return "Request"
case .response:
return "Response"
}
}
}

View File

@ -0,0 +1,38 @@
import Foundation
import CryptoKit
extension SignedMessage {
/// The message encoded to data
var encoded: Data {
mac + message.encoded
}
var bytes: [UInt8] {
Array(encoded)
}
/**
Create a message from received bytes.
- Parameter data: The sequence of bytes
- Note: The sequence must contain at least `Message.length` bytes, or the function will crash.
*/
init(decodeFrom data: Data) throws {
guard data.count == SignedMessage.size else {
print("Invalid signed message size \(data.count)")
throw MessageResult.invalidMessageSizeFromDevice
}
let count = SHA256.byteCount
self.mac = data.prefix(count)
self.message = try Message(decodeFrom: data.dropFirst(count))
}
/**
Check if the message contains a valid authentication code
- Parameter key: The key used to sign the message.
- Returns: `true`, if the message is valid.
*/
func isValid(using key: SymmetricKey) -> Bool {
HMAC<SHA256>.isValidAuthenticationCode(mac, authenticating: message.encoded, using: key)
}
}

View File

@ -0,0 +1,31 @@
import Foundation
/**
An authenticated message to or from the device.
*/
struct SignedMessage: Equatable, Hashable {
/// The message authentication code for the message (32 bytes)
let mac: Data
/// The message content
let message: Message
/**
Create an authenticated message
- Parameter mac: The message authentication code
- Parameter content: The message content
*/
init(mac: Data, message: Message) {
self.mac = mac
self.message = message
}
}
extension SignedMessage: Codable {
enum CodingKeys: Int, CodingKey {
case mac = 1
case message = 2
}
}

View File

@ -1,90 +0,0 @@
import Foundation
import NIOCore
/**
Encapsulates a response from a device.
*/
struct DeviceResponse {
/// Shorthand property for a timeout event.
static var deviceTimedOut: DeviceResponse {
.init(event: .deviceTimedOut)
}
/// Shorthand property for a disconnected event.
static var deviceNotConnected: DeviceResponse {
.init(event: .deviceNotConnected)
}
/// Shorthand property for a connected event.
static var deviceConnected: DeviceResponse {
.init(event: .deviceConnected)
}
/// Shorthand property for an unexpected socket event.
static var unexpectedSocketEvent: DeviceResponse {
.init(event: .unexpectedSocketEvent)
}
/// Shorthand property for an invalid message.
static var invalidMessageSize: DeviceResponse {
.init(event: .invalidMessageSize)
}
/// Shorthand property for missing body data.
static var noBodyData: DeviceResponse {
.init(event: .noBodyData)
}
/// Shorthand property for a busy connection
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?
/**
Decode a message from a buffer.
The buffer must contain `Message.length+1` bytes. The first byte denotes the event type,
the remaining bytes contain the message.
- Parameter buffer: The buffer where the message bytes are stored
*/
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)
}
/**
Create a response from an event without a message from the device.
- Parameter event: The response from the device.
*/
init(event: MessageResult) {
self.event = event
self.response = nil
}
/// Get the reponse encoded in bytes.
var encoded: Data {
guard let message = response else {
return event.encoded
}
return event.encoded + message.encoded
}
}

View File

@ -0,0 +1,17 @@
import Foundation
extension Data {
func convert<T>(into value: T) -> T {
withUnsafeBytes {
$0.baseAddress!.load(as: T.self)
}
}
init<T>(from value: T) {
var target = value
self = Swift.withUnsafeBytes(of: &target) {
Data($0)
}
}
}

View File

@ -40,20 +40,3 @@ extension Data {
}
}
}
extension Data {
func convert<T>(into value: T) -> T {
withUnsafeBytes {
$0.baseAddress!.load(as: T.self)
}
}
init<T>(from value: T) {
var target = value
self = Swift.withUnsafeBytes(of: &target) {
Data($0)
}
}
}

View File

@ -15,4 +15,7 @@ extension UInt32 {
var encoded: Data {
Data(from: CFSwapInt32HostToLittle(self))
}
/// The size of a `UInt32` when converted to data
static let byteSize = MemoryLayout<UInt32>.size
}

View File

@ -0,0 +1,8 @@
import Foundation
extension Message {
/// The byte length of an encoded message content
static let size: Int = 2 + 2 * UInt32.byteSize
}

View File

@ -1,179 +0,0 @@
import Foundation
import NIOCore
#if canImport(CryptoKit)
import CryptoKit
#else
import Crypto
#endif
/**
An authenticated message to or from the device.
*/
struct Message: Equatable, Hashable {
/// The message authentication code for the message (32 bytes)
let mac: Data
/// The message content
let content: Content
/**
Create an authenticated message
- Parameter mac: The message authentication code
- Parameter content: The message content
*/
init(mac: Data, content: Content) {
self.mac = mac
self.content = content
}
}
extension Message: Codable {
enum CodingKeys: Int, CodingKey {
case mac = 1
case content = 2
}
}
extension Message {
/**
The message content without authentication.
*/
struct Content: Equatable, Hashable {
/// The time of message creation, in UNIX time (seconds since 1970)
let time: UInt32
/// The counter of the message (for freshness)
let id: UInt32
let deviceId: UInt8?
/**
Create new message content.
- Parameter time: The time of message creation,
- Parameter id: The counter of the message
*/
init(time: UInt32, id: UInt32, device: UInt8) {
self.time = time
self.id = id
self.deviceId = device
}
/**
Decode message content from data.
The data consists of two `UInt32` encoded in little endian format
- Warning: The sequence must contain at least 8 bytes, or the function will crash.
- Parameter data: The sequence containing the bytes.
*/
init<T: Sequence>(decodeFrom data: T) where T.Element == UInt8 {
self.time = UInt32(data: Data(data.prefix(MemoryLayout<UInt32>.size)))
self.id = UInt32(data: Data(data.dropLast().suffix(MemoryLayout<UInt32>.size)))
self.deviceId = data.suffix(1).last!
}
/// The byte length of an encoded message content
static var length: Int {
MemoryLayout<UInt32>.size * 2 + 1
}
/// The message content encoded to data
var encoded: Data {
time.encoded + id.encoded + Data([deviceId ?? 0])
}
}
}
extension Message.Content: Codable {
enum CodingKeys: Int, CodingKey {
case time = 1
case id = 2
case deviceId = 3
}
}
extension Message {
/// The length of a message in bytes
static var length: Int {
SHA256.byteCount + Content.length
}
/**
Decode a message from a byte buffer.
The buffer must contain at least `Message.length` bytes, or it will return `nil`.
- Parameter buffer: The buffer containing the bytes.
*/
init?(decodeFrom buffer: ByteBuffer) {
guard let data = buffer.getBytes(at: 0, length: Message.length) else {
return nil
}
self.init(decodeFrom: data)
}
init?(decodeFrom data: Data, index: inout Int) {
guard index + Message.length <= data.count else {
return nil
}
self.init(decodeFrom: data.advanced(by: index))
index += Message.length
}
/// The message encoded to data
var encoded: Data {
mac + content.encoded
}
var bytes: [UInt8] {
Array(encoded)
}
/**
Create a message from received bytes.
- Parameter data: The sequence of bytes
- Note: The sequence must contain at least `Message.length` bytes, or the function will crash.
*/
init<T: Sequence>(decodeFrom data: T) where T.Element == UInt8 {
let count = SHA256.byteCount
self.mac = Data(data.prefix(count))
self.content = .init(decodeFrom: Array(data.dropFirst(count)))
}
/**
Check if the message contains a valid authentication code
- Parameter key: The key used to sign the message.
- Returns: `true`, if the message is valid.
*/
func isValid(using key: SymmetricKey) -> Bool {
HMAC<SHA256>.isValidAuthenticationCode(mac, authenticating: content.encoded, using: key)
}
}
extension Message.Content {
/**
Calculate an authentication code for the message content.
- Parameter key: The key to use to sign the content.
- Returns: The new message signed with the key.
*/
func authenticate(using key: SymmetricKey) -> Message {
let mac = HMAC<SHA256>.authenticationCode(for: encoded, using: key)
return .init(mac: Data(mac.map { $0 }), content: self)
}
/**
Calculate an authentication code for the message content and convert everything to data.
- Parameter key: The key to use to sign the content.
- Returns: The new message signed with the key, serialized to bytes.
*/
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
}
}

View File

@ -4,93 +4,232 @@ import Foundation
A result from sending a key to the device.
*/
enum MessageResult: UInt8 {
// MARK: Device status
/// Text content was received, although binary data was expected
/// The message was accepted.
case messageAccepted = 0
/// The web socket received text while waiting for binary data.
case textReceived = 1
/// A socket event on the device was unexpected (not binary data)
/// An unexpected socket event occured while performing the exchange.
case unexpectedSocketEvent = 2
/// The size of the payload (i.e. message) was invalid
case invalidMessageSize = 3
/// The received message size is invalid.
case invalidMessageSizeFromRemote = 3
/// The transmitted message could not be authenticated using the key
case messageAuthenticationFailed = 4
/// The message signature was incorrect.
case invalidSignatureFromRemote = 4
/// The message time was not within the acceptable bounds
case messageTimeMismatch = 5
/// The server challenge of the message did not match previous messages
case invalidServerChallengeFromRemote = 5
/// A later key has been used, invalidating this key (to prevent replay attacks after blocked communication)
case messageCounterInvalid = 6
/// The client challenge of the message did not match previous messages
case invalidClientChallengeFromRemote = 6
/// The key was accepted by the device, and the door will be opened
case messageAccepted = 7
/// An unexpected or unsupported message type was received
case invalidMessageTypeFromRemote = 7
/// A message is already being processed
case tooManyRequests = 8
/// The device id is invalid
case messageDeviceInvalid = 8
/// The received message result was not ``messageAccepted``
case invalidMessageResultFromRemote = 9
/// An invalid Url parameter was set sending a message to the device over a local connection
case invalidUrlParameter = 10
// MARK: Server status
/// The body data posting a message was missing or of wrong length
case noOrInvalidBodyDataFromRemote = 21
/// The authentication token for the server was invalid
case invalidServerAuthenticationFromRemote = 22
/// The request took too long to complete
case deviceTimedOut = 23
/// The device is not connected to the server via web socket
case deviceNotConnected = 24
/// The device sent a response of invalid size
case invalidMessageSizeFromDevice = 25
/// The header with the authentication token was missing or invalid (not a hex string) from a server request.
case missingOrInvalidAuthenticationHeaderFromRemote = 26
/// The server produced an internal error (500)
case internalServerError = 27
// MARK: Remote status
/// The request did not contain body data with the key
case noBodyData = 10
/// The initial state without information about the connection
case notChecked = 30
/// The url string is not a valid url
case serverUrlInvalid = 31
/// The device key or auth token is missing for a request.
case noKeyAvailable = 32
/// The Sesame server behind the proxy could not be found (502)
case serviceBehindProxyUnavailable = 33
/// The server url could not be found (404)
case pathOnServerNotFound = 34
/// The url session request returned an unknown response
case unexpectedUrlResponseType = 35
/// The request to the server returned an unhandled HTTP code
case unexpectedServerResponseCode = 36
/// A valid server challenge was received
case deviceAvailable = 37
case invalidSignatureFromDevice = 38
case invalidMessageTypeFromDevice = 39
case unknownMessageResultFromDevice = 40
/// The device sent a message with an invalid client challenge
case invalidClientChallengeFromDevice = 41
/// The device used an invalid server challenge in a response
case invalidServerChallengeFromDevice = 42
/// The unlock process was successfully completed
case unlocked = 43
}
/// 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
/// The device is connected
case deviceConnected = 15
case invalidUrlParameter = 20
case invalidResponseAuthentication = 21
extension MessageResult: Error {
}
extension MessageResult: CustomStringConvertible {
var description: String {
switch self {
case .messageAccepted:
return "Message accepted"
case .textReceived:
return "The device received unexpected text"
case .unexpectedSocketEvent:
return "Unexpected socket event for the device"
case .invalidMessageSize:
return "Invalid message data"
case .messageAuthenticationFailed:
case .invalidMessageSizeFromRemote:
return "Invalid message data from remote"
case .invalidSignatureFromRemote:
return "Message authentication failed"
case .messageTimeMismatch:
return "Message time invalid"
case .messageCounterInvalid:
return "Message counter invalid"
case .messageAccepted:
return "Message accepted"
case .messageDeviceInvalid:
return "Invalid device ID"
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"
case .deviceConnected:
return "The device is connected"
case .invalidServerChallengeFromRemote:
return "Remote used wrong server challenge"
case .invalidClientChallengeFromRemote:
return "Wrong client challenge sent"
case .invalidMessageTypeFromRemote:
return "Message type from remote invalid"
case .tooManyRequests:
return "Device busy"
case .invalidMessageResultFromRemote:
return "Invalid message result"
case .invalidUrlParameter:
return "The url parameter could not be found"
case .invalidResponseAuthentication:
return "The response could not be authenticated"
case .noOrInvalidBodyDataFromRemote:
return "Invalid body data in server request"
case .invalidServerAuthenticationFromRemote:
return "Invalid server token"
case .deviceTimedOut:
return "The device did not respond"
case .deviceNotConnected:
return "Device not connected to server"
case .invalidMessageSizeFromDevice:
return "Invalid device message size"
case .missingOrInvalidAuthenticationHeaderFromRemote:
return "Invalid server token format"
case .internalServerError:
return "Internal server error"
case .notChecked:
return "Not checked"
case .serverUrlInvalid:
return "Invalid server url"
case .noKeyAvailable:
return "No key available"
case .serviceBehindProxyUnavailable:
return "Service behind proxy not found"
case .pathOnServerNotFound:
return "Invalid server path"
case .unexpectedUrlResponseType:
return "Unexpected URL response"
case .unexpectedServerResponseCode:
return "Unexpected server response code"
case .deviceAvailable:
return "Device available"
case .invalidSignatureFromDevice:
return "Invalid device signature"
case .invalidMessageTypeFromDevice:
return "Message type from device invalid"
case .unknownMessageResultFromDevice:
return "Unknown message result"
case .invalidClientChallengeFromDevice:
return "Device used wrong client challenge"
case .invalidServerChallengeFromDevice:
return "Invalid"
case .unlocked:
return "Unlocked"
}
}
}
extension MessageResult: Codable {
}
extension MessageResult {
var encoded: Data {
Data([rawValue])
}
}
extension MessageResult {
init(httpCode: Int) {
switch httpCode {
case 200: self = .messageAccepted
case 204: self = .noOrInvalidBodyDataFromRemote
case 403: self = .invalidServerAuthenticationFromRemote
case 404: self = .pathOnServerNotFound
case 408: self = .deviceTimedOut
case 412: self = .deviceNotConnected
case 413: self = .invalidMessageSizeFromDevice
case 422: self = .missingOrInvalidAuthenticationHeaderFromRemote
case 429: self = .tooManyRequests
case 500: self = .internalServerError
case 501: self = .unexpectedServerResponseCode
case 502: self = .serviceBehindProxyUnavailable
default: self = .unexpectedServerResponseCode
}
}
var statusCode: Int {
switch self {
case .messageAccepted: return 200 // ok
case .noOrInvalidBodyDataFromRemote: return 204 // noContent
case .invalidServerAuthenticationFromRemote: return 403 // forbidden
case .pathOnServerNotFound: return 404 // notFound
case .deviceTimedOut: return 408 // requestTimeout
case .invalidMessageSizeFromRemote: return 411 // lengthRequired
case .deviceNotConnected: return 412 // preconditionFailed
case .invalidMessageSizeFromDevice: return 413 // payloadTooLarge
case .missingOrInvalidAuthenticationHeaderFromRemote: return 422 // unprocessableEntity
case .tooManyRequests: return 429 // tooManyRequests
case .internalServerError: return 500 // internalServerError
case .unexpectedServerResponseCode: return 501 // notImplemented
case .serviceBehindProxyUnavailable: return 502 // badGateway
default: return 501 // == unexpectedServerResponseCode
}
}
}

View File

@ -1,26 +0,0 @@
import Foundation
import NIOCore
#if canImport(CryptoKit)
import CryptoKit
#else
import Crypto
#endif
struct ServerMessage {
static let authTokenSize = SHA256.byteCount
let authToken: Data
let message: Message
init(authToken: Data, message: Message) {
self.authToken = authToken
self.message = message
}
var encoded: Data {
authToken + message.encoded
}
}

View File

@ -0,0 +1,14 @@
import Foundation
#if canImport(CryptoKit)
import CryptoKit
#else
import Crypto
#endif
enum SesameHeader {
static let authenticationHeader = "Authorization"
static let serverAuthenticationTokenSize = SHA256.byteCount
}

View File

@ -3,10 +3,7 @@ import Foundation
/**
The active urls on the server, for the device and the remote to connect
*/
enum RouteAPI: String {
/// Check the device status
case getDeviceStatus = "status"
enum SesameRoute: String {
/// Send a message to the server, to relay to the device
case postMessage = "message"

View File

@ -0,0 +1,15 @@
import Foundation
#if canImport(CryptoKit)
import CryptoKit
#else
import Crypto
#endif
extension SignedMessage {
/// The length of a message in bytes
static var size: Int {
SHA256.byteCount + Message.size
}
}

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

View File

@ -1,288 +1,98 @@
import SwiftUI
import SwiftData
import CryptoKit
struct ContentView: View {
@AppStorage("server")
var serverPath: String = "https://christophhagen.de/sesame/"
private let unlockButtonSize: CGFloat = 250
private let smallButtonSize: CGFloat = 50
private let buttonBackground: Color = .white.opacity(0.2)
private let buttonColor: Color = .white
@AppStorage("localIP")
var localAddress: String = "192.168.178.104/"
@AppStorage("counter")
var nextMessageCounter: Int = 0
@AppStorage("compensate")
var isCompensatingDaylightTime: Bool = false
@AppStorage("local")
private var useLocalConnection = false
@AppStorage("deviceID")
private var deviceID: Int = 0
@ObservedObject
var keyManager = KeyManagement()
let history = HistoryManager()
var coordinator: RequestCoordinator
@State
var state: ClientState = .noKeyAvailable
@State
private var timer: Timer?
@State
private var hasActiveRequest = false
@State
private var responseTime: Date? = nil
@State
private var showSettingsSheet = false
@State
private var showHistorySheet = false
@State private var showSettingsSheet = false
@State private var showHistorySheet = false
@State private var didShowKeySheetOnce = false
@State
private var didShowKeySheetOnce = false
init(modelContext: ModelContext) {
self.coordinator = .init(modelContext: modelContext)
}
let server = Client()
var compensationTime: UInt32 {
isCompensatingDaylightTime ? 3600 : 0
}
var isPerformingRequests: Bool {
hasActiveRequest ||
state == .waitingForResponse
}
var buttonBackground: Color {
state.allowsAction ?
.white.opacity(0.2) :
.black.opacity(0.2)
}
let buttonBorderWidth: CGFloat = 3
var buttonColor: Color {
state.allowsAction ? .white : .gray
}
private let buttonWidth: CGFloat = 250
private let smallButtonHeight: CGFloat = 50
private let smallButtonWidth: CGFloat = 120
private let smallButtonBorderWidth: CGFloat = 1
var body: some View {
GeometryReader { geo in
VStack(spacing: 20) {
HStack {
Button("History", action: { showHistorySheet = true })
.frame(width: smallButtonWidth,
height: smallButtonHeight)
.background(.white.opacity(0.2))
.cornerRadius(smallButtonHeight / 2)
.overlay(RoundedRectangle(cornerRadius: smallButtonHeight / 2).stroke(lineWidth: smallButtonBorderWidth).foregroundColor(.white))
.foregroundColor(.white)
.font(.title2)
.padding()
Spacer()
Button("Settings", action: { showSettingsSheet = true })
.frame(width: smallButtonWidth,
height: smallButtonHeight)
.background(.white.opacity(0.2))
.cornerRadius(smallButtonHeight / 2)
.overlay(RoundedRectangle(cornerRadius: smallButtonHeight / 2).stroke(lineWidth: smallButtonBorderWidth).foregroundColor(.white))
.foregroundColor(.white)
.font(.title2)
.padding()
}
VStack(spacing: 20) {
HStack {
Spacer()
if state.requiresDescription {
Text(state.description)
.padding()
Text("Sesame")
.font(.title)
Spacer()
}
Text(coordinator.state.description)
Spacer()
HStack(alignment: .bottom, spacing: 0) {
Button(action: { showHistorySheet = true }) {
Image(systemSymbol: .clockArrowCirclepath)
.foregroundColor(.white)
.frame(width: smallButtonSize, height: smallButtonSize)
.background(.white.opacity(0.2))
.cornerRadius(smallButtonSize / 2)
.font(.title2)
}
Button(state.actionText, action: mainButtonPressed)
.frame(width: buttonWidth,
height: buttonWidth)
Button("Unlock", action: coordinator.startUnlock)
.frame(width: unlockButtonSize, height: unlockButtonSize)
.background(buttonBackground)
.cornerRadius(buttonWidth / 2)
.overlay(RoundedRectangle(cornerRadius: buttonWidth / 2)
.stroke(lineWidth: buttonBorderWidth).foregroundColor(buttonColor))
.cornerRadius(unlockButtonSize / 2)
.foregroundColor(buttonColor)
.font(.title)
.disabled(!state.allowsAction)
.padding(.bottom, (geo.size.width-buttonWidth) / 2)
}
.background(state.color)
.onAppear {
if keyManager.hasAllKeys {
state = .requestingStatus
Button(action: { showSettingsSheet = true }) {
Image(systemSymbol: .gearshape)
.foregroundColor(.white)
.frame(width: smallButtonSize, height: smallButtonSize)
.background(.white.opacity(0.2))
.cornerRadius(smallButtonSize / 2)
.font(.title2)
}
startRegularStatusUpdates()
}
.onDisappear {
endRegularStatusUpdates()
}
.frame(width: geo.size.width, height: geo.size.height)
.animation(.easeInOut, value: state.color)
.sheet(isPresented: $showSettingsSheet) {
SettingsView(
keyManager: keyManager,
serverAddress: $serverPath,
localAddress: $localAddress,
deviceID: $deviceID,
nextMessageCounter: $nextMessageCounter,
isCompensatingDaylightTime: $isCompensatingDaylightTime,
useLocalConnection: $useLocalConnection)
}
.sheet(isPresented: $showHistorySheet) {
HistoryView(history: history)
Picker("Connection type", selection: $coordinator.connectionType) {
ForEach(ConnectionStrategy.allCases, id: \.rawValue) { connection in
Text(connection.description).tag(connection)
}
}
.pickerStyle(.segmented)
.padding(.horizontal, 30)
}
.background(coordinator.state.color)
.onAppear(perform: coordinator.startUpdatingServerChallenge)
.onDisappear(perform: coordinator.endUpdatingServerChallenge)
.animation(.easeInOut, value: coordinator.state.color)
.sheet(isPresented: $showSettingsSheet) {
SettingsView(
keyManager: coordinator.keyManager,
serverAddress: $coordinator.serverPath,
localAddress: $coordinator.localAddress)
}
.sheet(isPresented: $showHistorySheet) { HistoryView() }
.preferredColorScheme(.dark)
}
func mainButtonPressed() {
guard let key = keyManager.get(.remoteKey),
let token = keyManager.get(.authToken)?.data,
let deviceId = UInt8(exactly: deviceID) else {
return
}
let count = UInt32(nextMessageCounter)
let sentTime = Date()
// Add time to compensate that the device is using daylight savings time
let content = Message.Content(
time: sentTime.timestamp + compensationTime,
id: count,
device: deviceId)
let message = content.authenticate(using: key)
let historyItem = HistoryItem(sent: message.content, date: sentTime, local: useLocalConnection)
state = .waitingForResponse
print("Sending message \(count)")
Task {
let (newState, responseMessage) = await send(message, authToken: token)
let receivedTime = Date.now
responseTime = receivedTime
state = newState
let finishedItem = historyItem.didReceive(response: newState, date: receivedTime, message: responseMessage?.content)
guard let key = keyManager.get(.deviceKey) else {
save(historyItem: finishedItem.notAuthenticated())
return
}
guard let responseMessage else {
save(historyItem: finishedItem)
return
}
guard responseMessage.isValid(using: key) else {
save(historyItem: finishedItem.invalidated())
return
}
nextMessageCounter = Int(responseMessage.content.id)
save(historyItem: finishedItem)
}
}
private func send(_ message: Message, authToken: Data) async -> (state: ClientState, response: Message?) {
if useLocalConnection {
return await server.sendMessageOverLocalNetwork(message, server: localAddress)
} else {
return await server.send(message, server: serverPath, authToken: authToken)
}
}
private func save(historyItem: HistoryItem) {
do {
try history.save(item: historyItem)
} catch {
print("Failed to save item: \(error)")
}
}
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 !useLocalConnection else {
return
}
guard let authToken = keyManager.get(.authToken) else {
if !didShowKeySheetOnce {
didShowKeySheetOnce = true
//showSettingsSheet = true
}
return
}
guard !hasActiveRequest else {
return
}
hasActiveRequest = true
Task {
let newState = await server.deviceStatus(authToken: authToken.data, server: serverPath)
hasActiveRequest = false
switch state {
case .noKeyAvailable:
return
case .requestingStatus, .deviceNotAvailable, .ready:
state = newState
case .waitingForResponse:
return
case .messageRejected, .openSesame, .internalError, .responseRejected:
guard let time = responseTime else {
state = newState
return
}
responseTime = nil
// Wait at least 5 seconds after these states have been reached before changing the
// interface to allow sufficient time to see the result
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
}
}
}
}
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView()
.previewDevice("iPhone 8")
}
}
extension Date {
var timestamp: UInt32 {
UInt32(timeIntervalSince1970.rounded())
}
init(timestamp: UInt32) {
self.init(timeIntervalSince1970: TimeInterval(timestamp))
#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 ContentView(modelContext: container.mainContext)
.modelContainer(container)
} catch {
fatalError("Failed to create model container.")
}
}

View File

@ -0,0 +1,12 @@
import Foundation
extension Date {
var timestamp: UInt32 {
UInt32(timeIntervalSince1970.rounded())
}
init(timestamp: UInt32) {
self.init(timeIntervalSince1970: TimeInterval(timestamp))
}
}

View File

@ -1,114 +1,55 @@
import Foundation
import SwiftData
struct HistoryItem {
@Model
final class HistoryItem {
/// The sent/received date (local time, not including compensation offset)
let requestDate: Date
let request: Message.Content
let startDate: Date
let usedLocalConnection: Bool
let message: Message
var response: ClientState
let route: TransmissionType
let responseMessage: Message.Content?
let finishDate: Date
let responseDate: Date
init(sent message: Message.Content, sentDate: Date, local: Bool, response: ClientState, responseDate: Date, responseMessage: Message.Content?) {
self.requestDate = sentDate
self.request = message
self.responseMessage = responseMessage
self.response = response
self.responseDate = responseDate
self.usedLocalConnection = local
init(message: Message, startDate: Date, route: TransmissionType, finishDate: Date) {
self.startDate = startDate
self.message = message
self.finishDate = finishDate
self.route = route
}
// MARK: Statistics
var roundTripTime: TimeInterval {
responseDate.timeIntervalSince(requestDate)
}
var deviceTime: Date? {
guard let timestamp = responseMessage?.time else {
return nil
}
return Date(timestamp: timestamp)
}
var requestLatency: TimeInterval? {
deviceTime?.timeIntervalSince(requestDate)
}
var responseLatency: TimeInterval? {
guard let deviceTime = deviceTime else {
return nil
}
return responseDate.timeIntervalSince(deviceTime)
}
var clockOffset: Int? {
guard let deviceTime = deviceTime else {
return nil
}
let estimatedArrival = requestDate.advanced(by: roundTripTime / 2)
return Int(deviceTime.timeIntervalSince(estimatedArrival))
}
}
extension HistoryItem: Codable {
enum CodingKeys: Int, CodingKey {
case requestDate = 1
case request = 2
case usedLocalConnection = 3
case response = 4
case responseMessage = 5
case responseDate = 6
}
}
extension ClientState: Codable {
init(from decoder: Decoder) throws {
let code = try decoder.singleValueContainer().decode(UInt8.self)
self.init(code: code)
finishDate.timeIntervalSince(startDate)
}
func encode(to encoder: Encoder) throws {
var container = encoder.singleValueContainer()
try container.encode(code)
var response: MessageResult {
message.result
}
}
extension HistoryItem: Identifiable {
var id: UInt32 {
requestDate.timestamp
var id: Double {
startDate.timeIntervalSince1970
}
}
extension HistoryItem: Comparable {
static func < (lhs: HistoryItem, rhs: HistoryItem) -> Bool {
lhs.requestDate < rhs.requestDate
lhs.startDate < rhs.startDate
}
}
extension HistoryItem {
static var mock: HistoryItem {
let content = Message.Content(time: Date.now.timestamp, id: 123, device: 0)
let content2 = Message.Content(time: (Date.now + 1).timestamp, id: 124, device: 0)
let message = Message(messageType: .request, clientChallenge: 123, serverChallenge: 234, result: .unlocked)
return .init(
sent: content,
sentDate: .now,
local: false,
response: .openSesame,
responseDate: .now + 2,
responseMessage: content2)
message: message,
startDate: Date.now.addingTimeInterval(-5),
route: .throughServer,
finishDate: Date.now)
}
}

View File

@ -1,6 +1,8 @@
import SwiftUI
import SwiftData
import SFSafeSymbols
private let df: DateFormatter = {
let df = DateFormatter()
df.dateStyle = .short
@ -13,60 +15,55 @@ struct HistoryListItem: View {
let entry: HistoryItem
var entryTime: String {
df.string(from: entry.requestDate)
df.string(from: entry.startDate)
}
var roundTripText: String {
"\(Int(entry.roundTripTime * 1000)) ms"
}
var counterText: String {
let sentCounter = entry.request.id
let startText = "\(sentCounter)"
guard let rCounter = entry.responseMessage?.id else {
return startText
}
let diff = Int(rCounter) - Int(sentCounter)
guard diff != 1 && diff != 0 else {
return startText
}
return startText + " (\(diff))"
var clientNonceText: String {
"\(entry.message.clientChallenge)"
}
var timeOffsetText: String? {
guard let offset = entry.clockOffset else {
return nil
}
return "\(offset) s"
var serverNonceText: String {
"\(entry.message.serverChallenge)"
}
var body: some View {
VStack(alignment: .leading) {
HStack {
Image(systemSymbol: entry.route.symbol)
Text(entry.response.description)
.font(.headline)
Spacer()
Text(entryTime)
}.padding(.bottom, 1)
}
HStack {
Image(systemSymbol: entry.usedLocalConnection ? .wifi : .network)
Text(roundTripText)
.font(.subheadline)
Image(systemSymbol: .personalhotspot)
Text(counterText)
.font(.subheadline)
if let timeOffsetText {
Image(systemSymbol: .stopwatch)
Text(timeOffsetText)
.font(.subheadline)
}
}.foregroundColor(.secondary)
Image(systemSymbol: .arrowUpArrowDownCircle)
Text(roundTripText).padding(.trailing)
Image(systemSymbol: .lockIphone)
Text(clientNonceText).padding(.trailing)
Image(systemSymbol: .doorRightHandClosed)
Text(serverNonceText).padding(.trailing)
}
.foregroundColor(.secondary)
.font(.footnote)
}
}
}
struct HistoryListItem_Previews: PreviewProvider {
static var previews: some View {
HistoryListItem(entry: .mock)
#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 HistoryListItem(entry: item)
.modelContainer(container)
} catch {
fatalError("Failed to create model container.")
}
}

View File

@ -1,14 +1,14 @@
import SwiftUI
import SwiftData
struct HistoryView: View {
let history: HistoryManagerProtocol
@State
@Query
private var items: [HistoryItem] = []
@State
private var unlockCount = 0
private var unlockCount: Int {
items.count { $0.response == .unlocked }
}
private var percentage: Double {
guard items.count > 0 else {
@ -16,12 +16,19 @@ struct HistoryView: View {
}
return Double(unlockCount * 100) / Double(items.count)
}
private var requestNumberText: String {
guard items.count != 1 else {
return "1 Request"
}
return "\(items.count) Requests"
}
var body: some View {
NavigationView {
List {
HStack {
Text("\(items.count) requests")
Text(requestNumberText)
.foregroundColor(.primary)
.font(.body)
Spacer()
@ -35,26 +42,20 @@ struct HistoryView: View {
}
.navigationTitle("History")
}
.onAppear {
load()
}
}
private func load() {
Task {
let entries = history.loadEntries()
DispatchQueue.main.async {
items = entries
unlockCount = items.count {
$0.response == .openSesame
}
}
}
}
}
struct HistoryView_Previews: PreviewProvider {
static var previews: some View {
HistoryView(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 HistoryView()
.modelContainer(container)
} catch {
fatalError("Failed to create model container.")
}
}

View File

@ -1,10 +1,24 @@
import SwiftUI
import SwiftData
@main
struct SesameApp: App {
var body: some Scene {
WindowGroup {
ContentView()
@State
var modelContainer: ModelContainer
init() {
do {
self.modelContainer = try ModelContainer(for: HistoryItem.self)
} catch {
fatalError("Failed to create model container: \(error)")
}
}
var body: some Scene {
WindowGroup {
ContentView(modelContext: modelContainer.mainContext)
}
.modelContainer(modelContainer)
}
}

View File

@ -10,31 +10,6 @@ struct SettingsView: View {
@Binding
var localAddress: String
@Binding
var deviceID: Int
@Binding
var nextMessageCounter: Int
@Binding
var isCompensatingDaylightTime: Bool
@Binding
var useLocalConnection: Bool
@State
private var showDeviceIdInput = false
@State
private var deviceIdText = ""
@State
private var showCounterInput = false
@State
private var counterText = ""
var body: some View {
NavigationView {
ScrollView {
@ -53,49 +28,11 @@ struct SettingsView: View {
.foregroundColor(.secondary)
.padding(.leading, 8)
}.padding(.vertical, 8)
Toggle(isOn: $useLocalConnection) {
Text("Use direct connection to device")
}
Text("Attempt to communicate directly with the device. This is useful if the server is unavailable. Requires a WiFi connection on the same network as the device.")
.font(.caption)
.foregroundColor(.secondary)
VStack(alignment: .leading) {
Text("Device id")
.bold()
HStack(alignment: .bottom) {
Text("\(deviceID)")
.font(.system(.body, design: .monospaced))
.foregroundColor(.secondary)
.padding([.trailing, .bottom])
Button("Edit", action: showAlertToChangeDeviceID)
.padding([.horizontal, .bottom])
.padding(.top, 4)
}
}.padding(.vertical, 8)
VStack(alignment: .leading) {
Text("Message counter")
.bold()
HStack(alignment: .bottom) {
Text("\(nextMessageCounter)")
.font(.system(.body, design: .monospaced))
.foregroundColor(.secondary)
.padding([.trailing, .bottom])
Button("Edit", action: showAlertToChangeCounter)
.padding([.horizontal, .bottom])
.padding(.top, 4)
}
}.padding(.vertical, 8)
ForEach(KeyManagement.KeyType.allCases) { keyType in
SingleKeyView(
keyManager: keyManager,
type: keyType)
}
Toggle(isOn: $isCompensatingDaylightTime) {
Text("Compensate daylight savings time")
}
Text("If the remote has daylight savings time wrongly set, then the time validation will fail. Use this option to send messages with adjusted timestamps. Warning: Incorrect use of this option will allow replay attacks.")
.font(.caption)
.foregroundColor(.secondary)
}.padding()
}.onDisappear {
if !localAddress.hasSuffix("/") {
@ -103,54 +40,8 @@ struct SettingsView: View {
}
}
.navigationTitle("Settings")
.alert("Update device ID", isPresented: $showDeviceIdInput, actions: {
TextField("Device ID", text: $deviceIdText)
.keyboardType(.decimalPad)
.font(.system(.body, design: .monospaced))
.foregroundColor(.black)
Button("Save", action: saveDeviceID)
Button("Cancel", role: .cancel, action: {})
}, message: {
Text("Enter the device ID")
})
.alert("Update message counter", isPresented: $showCounterInput, actions: {
TextField("Message counter", text: $counterText)
.keyboardType(.decimalPad)
.font(.system(.body, design: .monospaced))
.foregroundColor(.black)
Button("Save", action: saveCounter)
Button("Cancel", role: .cancel, action: {})
}, message: {
Text("Enter the message counter")
})
}
}
private func showAlertToChangeDeviceID() {
deviceIdText = "\(deviceID)"
showDeviceIdInput = true
}
private func saveDeviceID() {
guard let id = UInt8(deviceIdText) else {
print("Invalid device id '\(deviceIdText)'")
return
}
self.deviceID = Int(id)
}
private func showAlertToChangeCounter() {
counterText = "\(nextMessageCounter)"
showCounterInput = true
}
private func saveCounter() {
guard let id = UInt32(counterText) else {
print("Invalid message counter '\(counterText)'")
return
}
self.nextMessageCounter = Int(id)
}
}
struct SettingsView_Previews: PreviewProvider {
@ -158,10 +49,6 @@ struct SettingsView_Previews: PreviewProvider {
SettingsView(
keyManager: KeyManagement(),
serverAddress: .constant("https://example.com"),
localAddress: .constant("192.168.178.42"),
deviceID: .constant(0),
nextMessageCounter: .constant(12345678),
isCompensatingDaylightTime: .constant(true),
useLocalConnection: .constant(false))
localAddress: .constant("192.168.178.42"))
}
}