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