Update to challenge-response system

This commit is contained in:
Christoph Hagen 2023-12-08 12:39:10 +01:00
parent ac22fcd4eb
commit e76029270a
16 changed files with 304 additions and 206 deletions

View File

@ -11,6 +11,7 @@ let package = Package(
.package(url: "https://github.com/christophhagen/Clairvoyant", from: "0.13.0"),
.package(url: "https://github.com/christophhagen/ClairvoyantVapor", from: "0.5.0"),
.package(url: "https://github.com/christophhagen/ClairvoyantBinaryCodable", from: "0.3.1"),
.package(url: "https://github.com/apple/swift-crypto.git", "1.0.0" ..< "4.0.0"),
],
targets: [
.executableTarget(
@ -20,6 +21,7 @@ let package = Package(
.product(name: "Clairvoyant", package: "Clairvoyant"),
.product(name: "ClairvoyantVapor", package: "ClairvoyantVapor"),
.product(name: "ClairvoyantBinaryCodable", package: "ClairvoyantBinaryCodable"),
.product(name: "Crypto", package: "swift-crypto"),
]
)
]

View File

@ -0,0 +1,5 @@
import Foundation
enum Message {
}

View File

@ -0,0 +1,9 @@
import Foundation
import RoutingKit
extension SesameRoute {
var path: PathComponent {
.init(stringLiteral: rawValue)
}
}

View File

@ -0,0 +1,5 @@
import Foundation
enum SignedMessage {
}

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

@ -16,4 +16,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

@ -3,98 +3,212 @@ import Foundation
/**
A result from sending a key to the device.
*/
enum MessageResult: UInt8, Error {
enum MessageResult: UInt8 {
/// 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 invalidSignatureByRemote = 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 serverChallengeMismatch = 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 clientChallengeMismatchFromRemote = 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
/// The device id is invalid
case messageDeviceInvalid = 8
/// A message is already being processed
case tooManyRequests = 8
/// The received message result was not ``messageAccepted``
case invalidMessageResultFromRemote = 9
/// The request did not contain body data with the key
case noBodyData = 10
/// An invalid Url parameter was set sending a message to the device over a local connection
case invalidUrlParameter = 10
/// The device is not connected
case deviceNotConnected = 12
/// The request took too long to complete
case deviceTimedOut = 20
/// The device did not respond within the timeout
case deviceTimedOut = 13
case noOrInvalidBodyDataInServerRequest = 21
/// Another message is being processed by the device
case operationInProgress = 14
/// The device is not connected to the server via web socket
case deviceNotConnected = 22
case serverNotReached = 23
case serverUrlInvalid = 24
case invalidDeviceResponseSize = 25
case invalidSignatureByDevice = 26
case noKeyAvailable = 27
case unlocked = 28
case unknownMessageResultFromDevice = 29
/// The device is connected
case deviceConnected = 15
/// The device sent a message with an invalid client challenge
case clientChallengeMismatchFromDevice = 30
case invalidUrlParameter = 20
/// A valid server challenge was received
case deviceAvailable = 31
case invalidResponseAuthentication = 21
case invalidMessageTypeFromDevice = 32
/// The url session request returned an unknown response
case unexpectedUrlResponseType = 33
/// The request to the server returned an unhandled HTTP code
case unexpectedServerResponseCode = 34
/// The server produced an internal error (500)
case internalServerError = 35
/// The Sesame server behind the proxy could not be found (502)
case serviceBehindProxyUnavailable = 36
/// The server url could not be found (404)
case pathOnServerNotFound = 37
/// The header with the authentication token was missing or invalid (not a hex string) from a server request.
case missingOrInvalidAuthenticationHeader = 38
/// The authentication token for the server was invalid
case invalidServerAuthentication = 39
/// The device sent a response of invalid size
case invalidMessageSizeFromDevice = 40
}
extension MessageResult: Error {
case invalidDeviceResponse = 22
}
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 .invalidSignatureByRemote:
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 .noOrInvalidBodyDataInServerRequest:
return "Invalid body data in server request"
case .deviceNotConnected:
return "Device not connected"
return "Device not connected to server"
case .deviceTimedOut:
return "The device did not respond"
case .operationInProgress:
return "Another operation is in progress"
case .deviceConnected:
return "The device is connected"
case .serverChallengeMismatch:
return "Server challenge mismatch"
case .clientChallengeMismatchFromRemote:
return "Wrong client challenge sent"
case .invalidMessageTypeFromRemote:
return "Message type from remote invalid"
case .tooManyRequests:
return "Device busy"
case .invalidUrlParameter:
return "The url parameter could not be found"
case .invalidResponseAuthentication:
return "The response could not be authenticated"
case .invalidDeviceResponse:
return "The device responded with invalid data"
case .invalidMessageResultFromRemote:
return "Invalid message result"
case .serverNotReached:
return "Server unavailable"
case .serverUrlInvalid:
return "Invalid server url"
case .invalidDeviceResponseSize:
return "Invalid Response size"
case .invalidSignatureByDevice:
return "Invalid device signature"
case .noKeyAvailable:
return "No key available"
case .unlocked:
return "Unlocked"
case .unknownMessageResultFromDevice:
return "Unknown message result"
case .deviceAvailable:
return "Device available"
case .clientChallengeMismatchFromDevice:
return "Device sent invalid client challenge"
case .invalidMessageTypeFromDevice:
return "Message type from device invalid"
case .unexpectedUrlResponseType:
return "Unexpected URL response"
case .unexpectedServerResponseCode:
return "Unexpected server response code"
case .internalServerError:
return "Internal server error"
case .serviceBehindProxyUnavailable:
return "Service behind proxy not found"
case .pathOnServerNotFound:
return "Invalid server path"
case .missingOrInvalidAuthenticationHeader:
return "Invalid server token format"
case .invalidServerAuthentication:
return "Invalid server token"
case .invalidMessageSizeFromDevice:
return "Invalid device message size"
}
}
}
extension MessageResult: Codable {
}
extension MessageResult {
var encoded: Data {
Data([rawValue])
}
}
extension MessageResult {
init(httpCode: Int) {
switch httpCode {
case 200: self = .messageAccepted
case 204: self = .noOrInvalidBodyDataInServerRequest
case 403: self = .invalidServerAuthentication
case 404: self = .pathOnServerNotFound
case 408: self = .deviceTimedOut
case 412: self = .deviceNotConnected
case 413: self = .invalidMessageSizeFromDevice
case 422: self = .missingOrInvalidAuthenticationHeader
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 .noOrInvalidBodyDataInServerRequest: return 204 // noContent
case .invalidServerAuthentication: 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 .missingOrInvalidAuthenticationHeader: 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,47 +0,0 @@
import Foundation
import NIOCore
#if canImport(CryptoKit)
import CryptoKit
#else
import Crypto
#endif
struct ServerMessage {
static let authTokenSize = SHA256.byteCount
static let maxLength = authTokenSize + 200
let authToken: Data
let message: Data
/**
Decode a message from a byte buffer.
The buffer must contain at least `ServerMessage.length` bytes, or it will return `nil`.
- Parameter buffer: The buffer containing the bytes.
*/
init?(decodeFrom buffer: ByteBuffer) {
guard buffer.readableBytes < ServerMessage.maxLength else {
log("Received invalid message with \(buffer.readableBytes) bytes")
return nil
}
guard let data = buffer.getBytes(at: 0, length: buffer.readableBytes) else {
log("Failed to read bytes of received message")
return nil
}
self.authToken = Data(data.prefix(ServerMessage.authTokenSize))
self.message = Data(data.dropFirst(ServerMessage.authTokenSize))
}
static func token(from buffer: ByteBuffer) -> Data? {
guard buffer.readableBytes == authTokenSize else {
return nil
}
guard let bytes = buffer.getBytes(at: 0, length: authTokenSize) else {
return nil
}
return Data(bytes)
}
}

View File

@ -0,0 +1,14 @@
import Foundation
#if canImport(CryptoKit)
import CryptoKit
#else
import Crypto
#endif
enum SesameHeader {
static let authenticationHeader = "Authentication"
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

@ -20,6 +20,8 @@ final class DeviceManager {
private let messagesToDeviceMetric: Metric<Int>
let serverStatus: Metric<ServerStatus>
var deviceIsConnected: Bool {
guard let connection, !connection.isClosed else {
return false
@ -30,7 +32,7 @@ final class DeviceManager {
/// A promise to finish the request once the device responds or times out
private var requestInProgress: CheckedContinuation<Data, Error>?
init(deviceKey: Data, remoteKey: Data, deviceTimeout: Int64) {
init(deviceKey: Data, remoteKey: Data, deviceTimeout: Int64, serverStatus: Metric<ServerStatus>) {
self.deviceKey = deviceKey
self.remoteKey = remoteKey
self.deviceTimeout = deviceTimeout
@ -42,9 +44,15 @@ final class DeviceManager {
"sesame.messages",
name: "Forwarded Messages",
description: "The number of messages transmitted to the device")
self.serverStatus = serverStatus
}
func updateDeviceConnectionMetric() async {
func updateMetricsAfterSystemStart() async {
_ = try? await serverStatus.update(deviceIsConnected ? .nominal : .reducedFunctionality)
await updateDeviceConnectionMetric()
}
private func updateDeviceConnectionMetric() async {
_ = try? await deviceConnectedMetric.update(deviceIsConnected)
}
@ -55,13 +63,19 @@ final class DeviceManager {
// MARK: API
func sendMessageToDevice(_ message: Data, on eventLoop: EventLoop) async throws -> Data {
func sendMessageToDevice(_ message: Data, authToken: Data, on eventLoop: EventLoop) async throws -> Data {
guard message.count == SignedMessage.size else {
throw MessageResult.invalidMessageSizeFromDevice
}
guard SHA256.hash(data: authToken) == remoteKey else {
throw MessageResult.invalidServerAuthentication
}
guard let socket = connection, !socket.isClosed else {
connection = nil
throw MessageResult.deviceNotConnected
}
guard requestInProgress == nil else {
throw MessageResult.operationInProgress
throw MessageResult.tooManyRequests
}
do {
try await socket.send(Array(message))
@ -99,9 +113,10 @@ final class DeviceManager {
}
func processDeviceResponse(_ buffer: ByteBuffer) {
guard let data = buffer.getData(at: 0, length: buffer.readableBytes) else {
guard let data = buffer.getData(at: 0, length: buffer.readableBytes),
data.count == SignedMessage.size else {
log("Failed to get data buffer received from device")
self.resumeDeviceRequest(with: .invalidDeviceResponse)
self.resumeDeviceRequest(with: .invalidMessageSizeFromDevice)
return
}
self.resumeDeviceRequest(with: data)

View File

@ -6,7 +6,6 @@ import ClairvoyantBinaryCodable
var deviceManager: DeviceManager!
private var provider: VaporMetricProvider!
private var status: Metric<ServerStatus>!
private var asyncScheduler = MultiThreadedEventLoopGroup(numberOfThreads: 2)
@ -29,7 +28,7 @@ public func configure(_ app: Application) async throws {
let monitor = MetricObserver(logFileFolder: logFolder, logMetricId: "sesame.log")
MetricObserver.standard = monitor
status = Metric<ServerStatus>("sesame.status")
let status = Metric<ServerStatus>("sesame.status")
try await status.update(.initializing)
app.http.server.configuration.port = config.port
@ -37,19 +36,17 @@ public func configure(_ app: Application) async throws {
let keyFile = storageFolder.appendingPathComponent(config.keyFileName)
let (deviceKey, remoteKey) = try loadKeys(at: keyFile)
deviceManager = DeviceManager(deviceKey: deviceKey, remoteKey: remoteKey, deviceTimeout: config.deviceTimeout)
deviceManager = DeviceManager(deviceKey: deviceKey, remoteKey: remoteKey, deviceTimeout: config.deviceTimeout, serverStatus: status)
try routes(app)
routes(app)
provider = .init(observer: monitor, accessManager: config.authenticationTokens)
provider.asyncScheduler = asyncScheduler
provider.registerRoutes(app)
monitor.saveCurrentListOfMetricsToLogFolder()
try await status.update(.nominal)
// Update the metric of the device status to ensure that it is accurate
await deviceManager.updateDeviceConnectionMetric()
// Update the metric of the device and server status
await deviceManager.updateMetricsAfterSystemStart()
log("[\(df.string(from: Date()))] Server started")
}

View File

@ -1,84 +1,45 @@
import Vapor
extension RouteAPI {
var path: PathComponent {
.init(stringLiteral: rawValue)
}
var pathParameter: PathComponent {
.parameter(rawValue)
}
}
private func messageTransmission(_ req: Request) async throws -> Data {
guard let body = req.body.data else {
throw MessageResult.noBodyData
}
guard let message = ServerMessage(decodeFrom: body) else {
throw MessageResult.invalidMessageSize
}
guard deviceManager.authenticateRemote(message.authToken) else {
throw MessageResult.messageAuthenticationFailed
}
return try await deviceManager.sendMessageToDevice(message.message, on: req.eventLoop)
}
private func deviceStatus(_ req: Request) -> MessageResult {
guard let body = req.body.data else {
return .noBodyData
}
guard let authToken = ServerMessage.token(from: body) else {
return .invalidMessageSize
}
guard deviceManager.authenticateRemote(authToken) else {
return .messageAuthenticationFailed
}
guard deviceManager.deviceIsConnected else {
return .deviceNotConnected
}
return .deviceConnected
}
func routes(_ app: Application) throws {
/**
Get the connection status of the device.
The request expects the authentication token of the remote in the body data of the POST request.
The request returns one byte of data, which is the raw value of a `MessageResult`.
Possible results are `noBodyData`, `invalidMessageSize`, `deviceNotConnected`, `deviceConnected`.
*/
app.post(RouteAPI.getDeviceStatus.path) { request in
let result = deviceStatus(request)
return Response(status: .ok, body: .init(data: result.encoded))
}
func routes(_ app: Application) {
/**
Post a message to the device for unlocking.
The expects a `ServerMessage` in the body data of the POST request, containing the valid remote authentication token and the message to send to the device.
The expects a `Message` in the body data of the POST request, containing the message to send to the device.
Expects a header ``RouteAPI.authenticationHeader`` with the hexencoded authentication token with binary length ``ServerMessage.authTokenSize``.
The request returns one or `Message.length+1` bytes of data, where the first byte is the raw value of a `MessageResult`,
and the optional following bytes contain the response message of the device. This request does not complete until either the device responds or the request times out. The timeout is specified by `KeyManagement.deviceTimeout`.
The request returns the ``ServerMessage.messageSize`` bytes of data constituting the device response,
or a status code corresponding to a ``MessageResult``.
This request does not complete until either the device responds or the request times out.
The timeout is specified by the configuration parameter `deviceTimeout`.
*/
app.post(RouteAPI.postMessage.path) { request async throws in
app.post(SesameRoute.postMessage.path) { request async throws in
do {
let result = try await messageTransmission(request)
return Response(status: .ok, body: .init(data: result))
guard let authString = request.headers.first(name: SesameHeader.authenticationHeader),
let authToken = Data(fromHexEncodedString: authString),
authToken.count == SesameHeader.serverAuthenticationTokenSize else {
throw MessageResult.missingOrInvalidAuthenticationHeader
}
guard let body = request.body.data,
let message = body.getData(at: 0, length: body.readableBytes) else {
throw MessageResult.noOrInvalidBodyDataInServerRequest
}
let responseMessage = try await deviceManager.sendMessageToDevice(message, authToken: authToken, on: request.eventLoop)
return Response(status: .ok, body: .init(data: responseMessage))
} catch let error as MessageResult {
return Response(status: .ok, body: .init(data: error.encoded))
return Response(status: .init(statusCode: error.statusCode))
}
}
/**
Start a new websocket connection for the device to receive messages from the server
- Returns: Nothing
- Note: The first message from the device over the connection must be a valid auth token.
Start a new websocket connection for the device to receive messages from the server.
The request must contain a header ``RouteAPI.socketAuthenticationHeader`` with a valid authentication token.
*/
app.webSocket(RouteAPI.socket.path) { request, socket async in
guard let authToken = request.headers.first(name: "Authorization") else {
app.webSocket(SesameRoute.socket.path) { request, socket async in
guard let authToken = request.headers.first(name: SesameHeader.authenticationHeader) else {
try? await socket.close()
return
}