diff --git a/Package.swift b/Package.swift index 2028e1b..8b99087 100644 --- a/Package.swift +++ b/Package.swift @@ -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"), ] ) ] diff --git a/Sources/App/API Extensions/Message.swift b/Sources/App/API Extensions/Message.swift new file mode 100644 index 0000000..18b7075 --- /dev/null +++ b/Sources/App/API Extensions/Message.swift @@ -0,0 +1,5 @@ +import Foundation + +enum Message { + +} diff --git a/Sources/App/API Extensions/SesameRoute+Path.swift b/Sources/App/API Extensions/SesameRoute+Path.swift new file mode 100644 index 0000000..9a304fe --- /dev/null +++ b/Sources/App/API Extensions/SesameRoute+Path.swift @@ -0,0 +1,9 @@ +import Foundation +import RoutingKit + +extension SesameRoute { + + var path: PathComponent { + .init(stringLiteral: rawValue) + } +} diff --git a/Sources/App/API Extensions/SignedMessage.swift b/Sources/App/API Extensions/SignedMessage.swift new file mode 100644 index 0000000..3539ac9 --- /dev/null +++ b/Sources/App/API Extensions/SignedMessage.swift @@ -0,0 +1,5 @@ +import Foundation + +enum SignedMessage { + +} diff --git a/Sources/App/API/Extensions/Data+Coding.swift b/Sources/App/API/Extensions/Data+Coding.swift new file mode 100644 index 0000000..8fbcf45 --- /dev/null +++ b/Sources/App/API/Extensions/Data+Coding.swift @@ -0,0 +1,17 @@ +import Foundation + +extension Data { + + func convert(into value: T) -> T { + withUnsafeBytes { + $0.baseAddress!.load(as: T.self) + } + } + + init(from value: T) { + var target = value + self = Swift.withUnsafeBytes(of: &target) { + Data($0) + } + } +} diff --git a/Sources/App/API/Data+Extensions.swift b/Sources/App/API/Extensions/Data+Hex.swift similarity index 80% rename from Sources/App/API/Data+Extensions.swift rename to Sources/App/API/Extensions/Data+Hex.swift index 159efeb..f36b39f 100644 --- a/Sources/App/API/Data+Extensions.swift +++ b/Sources/App/API/Extensions/Data+Hex.swift @@ -40,20 +40,3 @@ extension Data { } } } - -extension Data { - - - func convert(into value: T) -> T { - withUnsafeBytes { - $0.baseAddress!.load(as: T.self) - } - } - - init(from value: T) { - var target = value - self = Swift.withUnsafeBytes(of: &target) { - Data($0) - } - } -} diff --git a/Sources/App/API/UInt32+Extensions.swift b/Sources/App/API/Extensions/UInt32+Coding.swift similarity index 81% rename from Sources/App/API/UInt32+Extensions.swift rename to Sources/App/API/Extensions/UInt32+Coding.swift index 12a8762..ea06597 100644 --- a/Sources/App/API/UInt32+Extensions.swift +++ b/Sources/App/API/Extensions/UInt32+Coding.swift @@ -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.size } diff --git a/Sources/App/API/Message+Size.swift b/Sources/App/API/Message+Size.swift new file mode 100644 index 0000000..bebc957 --- /dev/null +++ b/Sources/App/API/Message+Size.swift @@ -0,0 +1,8 @@ +import Foundation + +extension Message { + + /// The byte length of an encoded message content + static let size: Int = 2 + 2 * UInt32.byteSize + +} diff --git a/Sources/App/API/MessageResult.swift b/Sources/App/API/MessageResult.swift index ba41746..e23d741 100644 --- a/Sources/App/API/MessageResult.swift +++ b/Sources/App/API/MessageResult.swift @@ -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 - - - /// The request did not contain body data with the key - case noBodyData = 10 - - /// 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 + /// A message is already being processed + case tooManyRequests = 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 + + /// The request took too long to complete + case deviceTimedOut = 20 + + case noOrInvalidBodyDataInServerRequest = 21 + + /// 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 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 { 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 + } + } +} diff --git a/Sources/App/API/ServerMessage.swift b/Sources/App/API/ServerMessage.swift deleted file mode 100644 index 7dfa873..0000000 --- a/Sources/App/API/ServerMessage.swift +++ /dev/null @@ -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) - } -} diff --git a/Sources/App/API/SesameHeader.swift b/Sources/App/API/SesameHeader.swift new file mode 100644 index 0000000..c8754e4 --- /dev/null +++ b/Sources/App/API/SesameHeader.swift @@ -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 + +} diff --git a/Sources/App/API/RouteAPI.swift b/Sources/App/API/SesameRoute.swift similarity index 75% rename from Sources/App/API/RouteAPI.swift rename to Sources/App/API/SesameRoute.swift index 22e90f0..a7477e7 100644 --- a/Sources/App/API/RouteAPI.swift +++ b/Sources/App/API/SesameRoute.swift @@ -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" diff --git a/Sources/App/API/SignedMessage+Size.swift b/Sources/App/API/SignedMessage+Size.swift new file mode 100644 index 0000000..18d6c1c --- /dev/null +++ b/Sources/App/API/SignedMessage+Size.swift @@ -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 + } +} diff --git a/Sources/App/DeviceManager.swift b/Sources/App/DeviceManager.swift index 72a8779..bbeb864 100644 --- a/Sources/App/DeviceManager.swift +++ b/Sources/App/DeviceManager.swift @@ -20,6 +20,8 @@ final class DeviceManager { private let messagesToDeviceMetric: Metric + let serverStatus: Metric + 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? - init(deviceKey: Data, remoteKey: Data, deviceTimeout: Int64) { + init(deviceKey: Data, remoteKey: Data, deviceTimeout: Int64, serverStatus: Metric) { 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 updateMetricsAfterSystemStart() async { + _ = try? await serverStatus.update(deviceIsConnected ? .nominal : .reducedFunctionality) + await updateDeviceConnectionMetric() } - func updateDeviceConnectionMetric() async { + 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) diff --git a/Sources/App/configure.swift b/Sources/App/configure.swift index 5772004..1979931 100755 --- a/Sources/App/configure.swift +++ b/Sources/App/configure.swift @@ -6,7 +6,6 @@ import ClairvoyantBinaryCodable var deviceManager: DeviceManager! private var provider: VaporMetricProvider! -private var status: Metric! 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("sesame.status") + let status = Metric("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") } diff --git a/Sources/App/routes.swift b/Sources/App/routes.swift index d85752a..3672849 100755 --- a/Sources/App/routes.swift +++ b/Sources/App/routes.swift @@ -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 }