Challenge-response, SwiftData, new UI

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

View File

@ -1,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
}
}