Challenge-response, SwiftData, new UI
This commit is contained in:
26
Sesame/API Extensions/Message+Crypto.swift
Normal file
26
Sesame/API Extensions/Message+Crypto.swift
Normal 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
|
||||
}
|
||||
}
|
123
Sesame/API Extensions/Message.swift
Normal file
123
Sesame/API Extensions/Message.swift
Normal 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))"
|
||||
}
|
||||
}
|
118
Sesame/API Extensions/MessageResult+UI.swift
Normal file
118
Sesame/API Extensions/MessageResult+UI.swift
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
57
Sesame/API Extensions/MessageType.swift
Normal file
57
Sesame/API Extensions/MessageType.swift
Normal 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"
|
||||
}
|
||||
}
|
||||
}
|
38
Sesame/API Extensions/SignedMessage+Crypto.swift
Normal file
38
Sesame/API Extensions/SignedMessage+Crypto.swift
Normal 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)
|
||||
}
|
||||
}
|
31
Sesame/API Extensions/SignedMessage.swift
Normal file
31
Sesame/API Extensions/SignedMessage.swift
Normal 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
|
||||
}
|
||||
}
|
Reference in New Issue
Block a user