diff --git a/Resources/device.key b/Resources/deviceKey similarity index 100% rename from Resources/device.key rename to Resources/deviceKey diff --git a/Sources/App/API.swift b/Sources/App/API.swift index 1810f62..2af6ad6 100644 --- a/Sources/App/API.swift +++ b/Sources/App/API.swift @@ -1,10 +1,7 @@ import Foundation enum PublicAPI: String { - case getDeviceResponse = "response" case getDeviceStatus = "status" - case clearKeyRequest = "clear" - case postKey = "key" - case postKeyIdParameter = "id" + case postMessage = "message" case socket = "listen" } diff --git a/Sources/App/KeyManagement.swift b/Sources/App/DeviceManager.swift similarity index 51% rename from Sources/App/KeyManagement.swift rename to Sources/App/DeviceManager.swift index e76571a..9a15e34 100644 --- a/Sources/App/KeyManagement.swift +++ b/Sources/App/DeviceManager.swift @@ -2,22 +2,18 @@ import Foundation import WebSocketKit import Vapor -final class KeyManagement { - - /// The security parameter for the keys (in bits) - private static let keySecurity = 128 - - /// The size of the individual keys in bytes - static let keySize = keySecurity / 8 +final class DeviceManager { /// The seconds to wait for a response from the device static let deviceTimeout: Int64 = 20 /// The connection to the device private var connection: WebSocket? - + + /// The authentication token of the device for the socket connection private let deviceKey: String - + + /// Indicate that the socket is fully initialized with an authorized device var deviceIsAuthenticated = false /// Indicator for device availability @@ -25,8 +21,8 @@ final class KeyManagement { !(connection?.isClosed ?? true) && deviceIsAuthenticated } - /// The id of the key which was sent to the device - private var keyInTransit: (id: UInt16, promise: EventLoopPromise)? + /// A promise to finish the request once the device responds or times out + private var requestInProgress: EventLoopPromise? init(deviceKey: String) { self.deviceKey = deviceKey @@ -38,26 +34,24 @@ final class KeyManagement { deviceIsConnected ? "1" : "0" } - func sendKeyToDevice(_ key: Data, keyId: UInt16, on eventLoop: EventLoop) -> EventLoopFuture { - guard key.count == KeyManagement.keySize else { - return eventLoop.makeSucceededFuture(.invalidPayloadSize) - } + func sendMessageToDevice(_ message: Message, on eventLoop: EventLoop) -> EventLoopFuture { guard let socket = connection, !socket.isClosed else { connection = nil return eventLoop.makeSucceededFuture(.deviceNotConnected) } - let keyIdData = [UInt8(keyId >> 8), UInt8(keyId & 0xFF)] - let promise = eventLoop.makePromise(of: KeyResult.self) - keyInTransit = (keyId, promise) - socket.send(keyIdData + key, promise: nil) + guard requestInProgress == nil else { + return eventLoop.makeSucceededFuture(.operationInProgress) + } + requestInProgress = eventLoop.makePromise(of: DeviceResponse.self) + socket.send(message.bytes, promise: nil) eventLoop.scheduleTask(in: .seconds(Self.deviceTimeout)) { [weak self] in - guard let (storedKeyId, promise) = self?.keyInTransit, storedKeyId == keyId else { + guard let promise = self?.requestInProgress else { return } - self?.keyInTransit = nil + self?.requestInProgress = nil promise.succeed(.deviceTimedOut) } - return promise.futureResult + return requestInProgress!.futureResult } func authenticateDevice(psk: String) { @@ -72,27 +66,12 @@ final class KeyManagement { } func processDeviceResponse(_ data: ByteBuffer) { - guard let (_, promise) = keyInTransit else { - print("No key in transit for response from device \(data)") + guard let promise = requestInProgress else { + print("No message in transit for response from device \(data)") return } - defer { keyInTransit = nil } - guard data.readableBytes == 1 else { - print("Unexpected number of bytes received from device") - promise.succeed(.unexpectedSocketEvent) - return - } - guard let rawValue = data.getBytes(at: 0, length: 1)?.first else { - print("Unreadable data received from device") - promise.succeed(.unexpectedSocketEvent) - return - } - guard let response = KeyResult(rawValue: rawValue) else { - print("Unknown response \(rawValue) received from device") - promise.succeed(.unexpectedSocketEvent) - return - } - promise.succeed(response) + defer { requestInProgress = nil } + promise.succeed(DeviceResponse(data) ?? .unexpectedSocketEvent) } func didCloseDeviceSocket() { diff --git a/Sources/App/DeviceResponse.swift b/Sources/App/DeviceResponse.swift new file mode 100644 index 0000000..6e47de0 --- /dev/null +++ b/Sources/App/DeviceResponse.swift @@ -0,0 +1,69 @@ +import Foundation +import NIOCore + + +struct DeviceResponse { + + static var deviceTimedOut: DeviceResponse { + .init(event: .deviceTimedOut) + } + + static var deviceNotConnected: DeviceResponse { + .init(event: .deviceNotConnected) + } + + static var unexpectedSocketEvent: DeviceResponse { + .init(event: .unexpectedSocketEvent) + } + + static var invalidMessageData: DeviceResponse { + .init(event: .invalidMessageData) + } + + static var noBodyData: DeviceResponse { + .init(event: .noBodyData) + } + + static var corruptkeyData: DeviceResponse { + .init(event: .corruptkeyData) + } + + static var operationInProgress: DeviceResponse { + .init(event: .operationInProgress) + } + + /// The response to a key from the server + let event: MessageResult + + /// The index of the next key to use + let response: Message? + + init?(_ buffer: ByteBuffer) { + guard let byte = buffer.getData(at: 0, length: 1) else { + print("No bytes received from device") + return nil + } + guard let event = MessageResult(rawValue: byte[0]) else { + print("Unknown response \(byte[0]) received from device") + return nil + } + self.event = event + guard let data = buffer.getSlice(at: 1, length: Message.length) else { + self.response = nil + return + } + self.response = Message(decodeFrom: data) + } + + init(event: MessageResult) { + self.event = event + self.response = nil + } + + var encoded: Data { + guard let message = response else { + return Data([event.rawValue]) + } + return Data([event.rawValue]) + message.encoded + } +} diff --git a/Sources/App/Message.swift b/Sources/App/Message.swift new file mode 100644 index 0000000..42811eb --- /dev/null +++ b/Sources/App/Message.swift @@ -0,0 +1,107 @@ +import Foundation +import CryptoKit +import NIOCore +import Vapor + +struct Message: Equatable, Hashable { + + static var length: Int { + SHA256Digest.byteCount + Content.length + } + + struct Content: Equatable, Hashable { + + let time: UInt32 + + let id: UInt32 + + init(time: UInt32, id: UInt32) { + self.time = time + self.id = id + } + + init(decodeFrom data: Data) { + self.time = UInt32(data: data[data.startIndex...size * 2 + } + + func authenticate(using key: SymmetricKey) -> Message { + let mac = HMAC.authenticationCode(for: encoded, using: key) + return .init(mac: Data(mac.map { $0 }), content: self) + } + + func authenticateAndSerialize(using key: SymmetricKey) -> Data { + let encoded = self.encoded + let mac = HMAC.authenticationCode(for: encoded, using: key) + return Data(mac.map { $0 }) + encoded + } + + var encoded: Data { + time.encoded + id.encoded + } + + var bytes: [UInt8] { + time.bytes + id.bytes + } + } + + let mac: Data + + let content: Content + + init(mac: Data, content: Content) { + self.mac = mac + self.content = content + } + + init?(decodeFrom buffer: ByteBuffer) { + guard let data = buffer.getData(at: 0, length: Message.length) else { + return nil + } + self.init(decodeFrom: data) + } + + private init(decodeFrom data: Data) { + let count = SHA256Digest.byteCount + self.mac = data[data.startIndex.. Bool { + HMAC.isValidAuthenticationCode(mac, authenticating: content.encoded, using: key) + } + + var encoded: Data { + mac + content.encoded + } + + var bytes: [UInt8] { + Array(mac) + content.bytes + } +} + + extension UInt32 { + + init(data: Data) { + self = data + .reversed() + .enumerated() + .map { UInt32($0.element) << ($0.offset * 8) } + .reduce(0, +) + + } + + var encoded: Data { + .init(bytes) + } + + var bytes: [UInt8] { + (0..<4).reversed().map { + UInt8((self >> ($0*8)) & 0xFF) + } + } +} diff --git a/Sources/App/Response.swift b/Sources/App/MessageResult.swift similarity index 59% rename from Sources/App/Response.swift rename to Sources/App/MessageResult.swift index 44dcbf7..7b4dfa0 100644 --- a/Sources/App/Response.swift +++ b/Sources/App/MessageResult.swift @@ -3,7 +3,7 @@ import Foundation /** A result from sending a key to the device. */ -enum KeyResult: UInt8 { +enum MessageResult: UInt8 { /// Text content was received, although binary data was expected case textReceived = 1 @@ -11,24 +11,22 @@ enum KeyResult: UInt8 { /// A socket event on the device was unexpected (not binary data) case unexpectedSocketEvent = 2 - /// The size of the payload (key id + key data, or just key) was invalid - case invalidPayloadSize = 3 + /// The size of the payload (i.e. message) was invalid, or the data could not be read + case invalidMessageData = 3 + + /// The transmitted message could not be authenticated using the key + case messageAuthenticationFailed = 4 - /// The index of the key was out of bounds - case invalidKeyIndex = 4 - - /// The transmitted key data did not match the expected key - case invalidKey = 5 - - /// The key has been previously used and is no longer valid - case keyAlreadyUsed = 6 + /// The message time was not within the acceptable bounds + case messageTimeMismatch = 5 /// A later key has been used, invalidating this key (to prevent replay attacks after blocked communication) - case keyWasSkipped = 7 + case messageCounterInvalid = 6 /// The key was accepted by the device, and the door will be opened - case keyAccepted = 8 - + case messageAccepted = 7 + + /// The device produced an unknown error case unknownDeviceError = 9 @@ -43,38 +41,41 @@ enum KeyResult: UInt8 { /// The device did not respond within the timeout case deviceTimedOut = 13 + + /// Another message is being processed by the device + case operationInProgress = 14 } -extension KeyResult: CustomStringConvertible { +extension MessageResult: CustomStringConvertible { var description: String { switch self { - case .invalidKeyIndex: - return "Invalid key id (too large)" - case .noBodyData: - return "No body data included in the request" - case .invalidPayloadSize: - return "Invalid key size" - case .corruptkeyData: - return "Key data corrupted" - case .deviceNotConnected: - return "Device not connected" case .textReceived: return "The device received unexpected text" case .unexpectedSocketEvent: return "Unexpected socket event for the device" - case .invalidKey: - return "The transmitted key was not correct" - case .keyAlreadyUsed: - return "The transmitted key was already used" - case .keyWasSkipped: - return "A newer key was already used" - case .keyAccepted: - return "Key successfully sent" + case .invalidMessageData: + return "Invalid message data" + case .messageAuthenticationFailed: + return "Message authentication failed" + case .messageTimeMismatch: + return "Message time invalid" + case .messageCounterInvalid: + return "Message counter invalid" + case .messageAccepted: + return "Message accepted" case .unknownDeviceError: return "The device experienced an unknown error" + case .noBodyData: + return "No body data included in the request" + case .corruptkeyData: + return "Key data corrupted" + case .deviceNotConnected: + return "Device not connected" case .deviceTimedOut: return "The device did not respond" + case .operationInProgress: + return "Another operation is in progress" } } } diff --git a/Sources/App/configure.swift b/Sources/App/configure.swift index 784a671..47fc52d 100755 --- a/Sources/App/configure.swift +++ b/Sources/App/configure.swift @@ -1,22 +1,22 @@ import Vapor -var keyManager: KeyManagement! +var deviceManager: DeviceManager! // configures your application public func configure(_ app: Application) throws { app.http.server.configuration.port = 6003 let storageFolder = URL(fileURLWithPath: app.directory.resourcesDirectory) - let keyFile = storageFolder.appendingPathComponent("device.key") + let keyFile = storageFolder.appendingPathComponent("deviceKey") let deviceKey = try String(contentsOf: keyFile) .trimmingCharacters(in: .whitespacesAndNewlines) - keyManager = KeyManagement(deviceKey: deviceKey) + deviceManager = DeviceManager(deviceKey: deviceKey) try routes(app) // Gracefully shut down by closing potentially open socket DispatchQueue.global(qos: .utility).asyncAfter(deadline: .now() + .seconds(5)) { _ = app.server.onShutdown.always { _ in - keyManager.removeDeviceConnection() + deviceManager.removeDeviceConnection() } } } diff --git a/Sources/App/routes.swift b/Sources/App/routes.swift index f6cfc98..663a84d 100755 --- a/Sources/App/routes.swift +++ b/Sources/App/routes.swift @@ -11,20 +11,14 @@ extension PublicAPI { } } -private func keyTransmission(_ req: Request) -> EventLoopFuture { - guard let keyId = req.parameters.get(PublicAPI.postKeyIdParameter.rawValue, as: UInt16.self) else { - return req.eventLoop.makeSucceededFuture(.invalidKeyIndex) - } +private func messageTransmission(_ req: Request) -> EventLoopFuture { guard let body = req.body.data else { return req.eventLoop.makeSucceededFuture(.noBodyData) } - guard body.readableBytes == KeyManagement.keySize else { - return req.eventLoop.makeSucceededFuture(.invalidPayloadSize) + guard let message = Message(decodeFrom: body) else { + return req.eventLoop.makeSucceededFuture(.invalidMessageData) } - guard let key = body.getData(at: 0, length: KeyManagement.keySize) else { - return req.eventLoop.makeSucceededFuture(.corruptkeyData) - } - return keyManager.sendKeyToDevice(key, keyId: keyId, on: req.eventLoop) + return deviceManager.sendMessageToDevice(message, on: req.eventLoop) } func routes(_ app: Application) throws { @@ -35,20 +29,19 @@ func routes(_ app: Application) throws { The response is a string of either "1" (connected) or "0" (disconnected) */ app.get(PublicAPI.getDeviceStatus.path) { req -> String in - keyManager.deviceStatus + deviceManager.deviceStatus } /** Post a key to the device for unlocking. - The corresponding integer key id for the key data must be contained in the url path. - - The request returns a string containing a `rawValue` of a `KeyPostResponse` - A success of this method does not yet signal successful unlocking. - The client should request the status by inquiring the device response. + 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`. */ - app.post(PublicAPI.postKey.path, PublicAPI.postKeyIdParameter.pathParameter) { req in - keyTransmission(req).map { String($0.rawValue) } + app.post(PublicAPI.postMessage.path) { req in + messageTransmission(req).map { + Response(status: .ok, body: .init(data: $0.encoded)) + } } /** @@ -58,15 +51,15 @@ func routes(_ app: Application) throws { */ app.webSocket(PublicAPI.socket.path) { req, socket in socket.onBinary { _, data in - keyManager.processDeviceResponse(data) + deviceManager.processDeviceResponse(data) } socket.onText { _, text in - keyManager.authenticateDevice(psk: text) + deviceManager.authenticateDevice(psk: text) } _ = socket.onClose.always { _ in - keyManager.didCloseDeviceSocket() + deviceManager.didCloseDeviceSocket() } - keyManager.createNewDeviceConnection(socket) + deviceManager.createNewDeviceConnection(socket) } } diff --git a/Tests/AppTests/AppTests.swift b/Tests/AppTests/AppTests.swift index 9817630..12ecc39 100644 --- a/Tests/AppTests/AppTests.swift +++ b/Tests/AppTests/AppTests.swift @@ -2,14 +2,44 @@ import XCTVapor final class AppTests: XCTestCase { - func testHelloWorld() throws { - let app = Application(.testing) - defer { app.shutdown() } - try configure(app) - try app.test(.GET, "hello", afterResponse: { res in - XCTAssertEqual(res.status, .ok) - XCTAssertEqual(res.body.string, "Hello, world!") - }) + func testEncodingUInt32() { + let input: UInt32 = 123 + let data = input.encoded + let output = UInt32(data: data) + XCTAssertEqual(input, output) + } + + func testEncodingContent() { + let input = Message.Content(time: 1234567890, id: 23456789) + let data = input.encoded + let output = Message.Content(decodeFrom: data) + XCTAssertEqual(input, output) + let data2 = Data([42, 42]) + data + let output2 = Message.Content(decodeFrom: Data(data2[2...])) + XCTAssertEqual(input, output2) + } + + func testEncodingMessage() { + let input = Message(mac: Data(repeating: 42, count: 32), + content: Message.Content(time: 1234567890, id: 23456789)) + let data = input.encoded + let buffer = ByteBuffer(data: data) + let output = Message(decodeFrom: buffer) + XCTAssertEqual(input, output) + } + + func testSigning() throws { + let key = SymmetricKey(size: .bits256) + let content = Message.Content(time: 1234567890, id: 23456789) + let input = content.authenticate(using: key) + XCTAssertTrue(input.isValid(using: key)) + + let data = content.authenticateAndSerialize(using: key) + let decoded = Message(decodeFrom: ByteBuffer(data: data)) + XCTAssertNotNil(decoded) + XCTAssertTrue(decoded!.isValid(using: key)) + XCTAssertEqual(decoded!, input) + XCTAssertEqual(content, input.content) } }