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/Clairvoyant", from: "0.13.0"),
.package(url: "https://github.com/christophhagen/ClairvoyantVapor", from: "0.5.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/christophhagen/ClairvoyantBinaryCodable", from: "0.3.1"),
.package(url: "https://github.com/apple/swift-crypto.git", "1.0.0" ..< "4.0.0"),
], ],
targets: [ targets: [
.executableTarget( .executableTarget(
@ -20,6 +21,7 @@ let package = Package(
.product(name: "Clairvoyant", package: "Clairvoyant"), .product(name: "Clairvoyant", package: "Clairvoyant"),
.product(name: "ClairvoyantVapor", package: "ClairvoyantVapor"), .product(name: "ClairvoyantVapor", package: "ClairvoyantVapor"),
.product(name: "ClairvoyantBinaryCodable", package: "ClairvoyantBinaryCodable"), .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 { var encoded: Data {
Data(from: CFSwapInt32HostToLittle(self)) 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. 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 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 case unexpectedSocketEvent = 2
/// The size of the payload (i.e. message) was invalid /// The received message size is invalid.
case invalidMessageSize = 3 case invalidMessageSizeFromRemote = 3
/// The transmitted message could not be authenticated using the key /// The message signature was incorrect.
case messageAuthenticationFailed = 4 case invalidSignatureByRemote = 4
/// The message time was not within the acceptable bounds /// The server challenge of the message did not match previous messages
case messageTimeMismatch = 5 case serverChallengeMismatch = 5
/// A later key has been used, invalidating this key (to prevent replay attacks after blocked communication) /// The client challenge of the message did not match previous messages
case messageCounterInvalid = 6 case clientChallengeMismatchFromRemote = 6
/// The key was accepted by the device, and the door will be opened /// An unexpected or unsupported message type was received
case messageAccepted = 7 case invalidMessageTypeFromRemote = 7
/// The device id is invalid /// A message is already being processed
case messageDeviceInvalid = 8 case tooManyRequests = 8
/// The received message result was not ``messageAccepted``
/// The request did not contain body data with the key case invalidMessageResultFromRemote = 9
case noBodyData = 10
/// An invalid Url parameter was set sending a message to the device over a local connection
/// The device is not connected case invalidUrlParameter = 10
case deviceNotConnected = 12
/// The request took too long to complete
/// The device did not respond within the timeout case deviceTimedOut = 20
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
/// The device is connected case serverNotReached = 23
case deviceConnected = 15 case serverUrlInvalid = 24
case invalidDeviceResponseSize = 25
case invalidUrlParameter = 20 case invalidSignatureByDevice = 26
case noKeyAvailable = 27
case invalidResponseAuthentication = 21 case unlocked = 28
case unknownMessageResultFromDevice = 29
/// The device sent a message with an invalid client challenge
case clientChallengeMismatchFromDevice = 30
/// A valid server challenge was received
case deviceAvailable = 31
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 { extension MessageResult: CustomStringConvertible {
var description: String { var description: String {
switch self { switch self {
case .messageAccepted:
return "Message accepted"
case .textReceived: case .textReceived:
return "The device received unexpected text" return "The device received unexpected text"
case .unexpectedSocketEvent: case .unexpectedSocketEvent:
return "Unexpected socket event for the device" return "Unexpected socket event for the device"
case .invalidMessageSize: case .invalidMessageSizeFromRemote:
return "Invalid message data" return "Invalid message data from remote"
case .messageAuthenticationFailed: case .invalidSignatureByRemote:
return "Message authentication failed" return "Message authentication failed"
case .messageTimeMismatch: case .noOrInvalidBodyDataInServerRequest:
return "Message time invalid" return "Invalid body data in server request"
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: case .deviceNotConnected:
return "Device not connected" return "Device not connected to server"
case .deviceTimedOut: case .deviceTimedOut:
return "The device did not respond" return "The device did not respond"
case .operationInProgress: case .serverChallengeMismatch:
return "Another operation is in progress" return "Server challenge mismatch"
case .deviceConnected: case .clientChallengeMismatchFromRemote:
return "The device is connected" return "Wrong client challenge sent"
case .invalidMessageTypeFromRemote:
return "Message type from remote invalid"
case .tooManyRequests:
return "Device busy"
case .invalidUrlParameter: case .invalidUrlParameter:
return "The url parameter could not be found" return "The url parameter could not be found"
case .invalidResponseAuthentication: case .invalidMessageResultFromRemote:
return "The response could not be authenticated" return "Invalid message result"
case .invalidDeviceResponse: case .serverNotReached:
return "The device responded with invalid data" 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 { extension MessageResult {
var encoded: Data { var encoded: Data {
Data([rawValue]) 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 The active urls on the server, for the device and the remote to connect
*/ */
enum RouteAPI: String { enum SesameRoute: String {
/// Check the device status
case getDeviceStatus = "status"
/// Send a message to the server, to relay to the device /// Send a message to the server, to relay to the device
case postMessage = "message" 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> private let messagesToDeviceMetric: Metric<Int>
let serverStatus: Metric<ServerStatus>
var deviceIsConnected: Bool { var deviceIsConnected: Bool {
guard let connection, !connection.isClosed else { guard let connection, !connection.isClosed else {
return false return false
@ -30,7 +32,7 @@ final class DeviceManager {
/// A promise to finish the request once the device responds or times out /// A promise to finish the request once the device responds or times out
private var requestInProgress: CheckedContinuation<Data, Error>? 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.deviceKey = deviceKey
self.remoteKey = remoteKey self.remoteKey = remoteKey
self.deviceTimeout = deviceTimeout self.deviceTimeout = deviceTimeout
@ -42,9 +44,15 @@ final class DeviceManager {
"sesame.messages", "sesame.messages",
name: "Forwarded Messages", name: "Forwarded Messages",
description: "The number of messages transmitted to the device") description: "The number of messages transmitted to the device")
self.serverStatus = serverStatus
}
func updateMetricsAfterSystemStart() async {
_ = try? await serverStatus.update(deviceIsConnected ? .nominal : .reducedFunctionality)
await updateDeviceConnectionMetric()
} }
func updateDeviceConnectionMetric() async { private func updateDeviceConnectionMetric() async {
_ = try? await deviceConnectedMetric.update(deviceIsConnected) _ = try? await deviceConnectedMetric.update(deviceIsConnected)
} }
@ -55,13 +63,19 @@ final class DeviceManager {
// MARK: API // 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 { guard let socket = connection, !socket.isClosed else {
connection = nil connection = nil
throw MessageResult.deviceNotConnected throw MessageResult.deviceNotConnected
} }
guard requestInProgress == nil else { guard requestInProgress == nil else {
throw MessageResult.operationInProgress throw MessageResult.tooManyRequests
} }
do { do {
try await socket.send(Array(message)) try await socket.send(Array(message))
@ -99,9 +113,10 @@ final class DeviceManager {
} }
func processDeviceResponse(_ buffer: ByteBuffer) { 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") log("Failed to get data buffer received from device")
self.resumeDeviceRequest(with: .invalidDeviceResponse) self.resumeDeviceRequest(with: .invalidMessageSizeFromDevice)
return return
} }
self.resumeDeviceRequest(with: data) self.resumeDeviceRequest(with: data)

View File

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

View File

@ -1,84 +1,45 @@
import Vapor import Vapor
extension RouteAPI { func routes(_ app: Application) {
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))
}
/** /**
Post a message to the device for unlocking. 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`, The request returns the ``ServerMessage.messageSize`` bytes of data constituting the device response,
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`. 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 { do {
let result = try await messageTransmission(request) guard let authString = request.headers.first(name: SesameHeader.authenticationHeader),
return Response(status: .ok, body: .init(data: result)) 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 { } 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 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. The request must contain a header ``RouteAPI.socketAuthenticationHeader`` with a valid authentication token.
*/ */
app.webSocket(RouteAPI.socket.path) { request, socket async in app.webSocket(SesameRoute.socket.path) { request, socket async in
guard let authToken = request.headers.first(name: "Authorization") else { guard let authToken = request.headers.first(name: SesameHeader.authenticationHeader) else {
try? await socket.close() try? await socket.close()
return return
} }