diff --git a/Resources/deviceKey b/Resources/deviceKey deleted file mode 100644 index 6dae4c1..0000000 --- a/Resources/deviceKey +++ /dev/null @@ -1 +0,0 @@ -access token diff --git a/Resources/keys b/Resources/keys new file mode 100644 index 0000000..228939f --- /dev/null +++ b/Resources/keys @@ -0,0 +1,2 @@ +access token +0000000000000000000000000000000000000000000000000000000000000000 diff --git a/Sources/App/API/Data+Extensions.swift b/Sources/App/API/Data+Extensions.swift new file mode 100644 index 0000000..159efeb --- /dev/null +++ b/Sources/App/API/Data+Extensions.swift @@ -0,0 +1,59 @@ +import Foundation + +extension Data { + + public var hexEncoded: String { + return map { String(format: "%02hhx", $0) }.joined() + } + + // Convert 0 ... 9, a ... f, A ...F to their decimal value, + // return nil for all other input characters + private func decodeNibble(_ u: UInt16) -> UInt8? { + switch(u) { + case 0x30 ... 0x39: + return UInt8(u - 0x30) + case 0x41 ... 0x46: + return UInt8(u - 0x41 + 10) + case 0x61 ... 0x66: + return UInt8(u - 0x61 + 10) + default: + return nil + } + } + + public init?(fromHexEncodedString string: String) { + let utf16 = string.utf16 + self.init(capacity: utf16.count/2) + + var i = utf16.startIndex + guard utf16.count % 2 == 0 else { + return nil + } + while i != utf16.endIndex { + guard let hi = decodeNibble(utf16[i]), + let lo = decodeNibble(utf16[utf16.index(i, offsetBy: 1, limitedBy: utf16.endIndex)!]) else { + return nil + } + var value = hi << 4 + lo + self.append(&value, count: 1) + i = utf16.index(i, offsetBy: 2, limitedBy: utf16.endIndex)! + } + } +} + +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/DeviceResponse.swift b/Sources/App/API/DeviceResponse.swift index 10e3749..b5157bc 100644 --- a/Sources/App/API/DeviceResponse.swift +++ b/Sources/App/API/DeviceResponse.swift @@ -16,6 +16,11 @@ struct DeviceResponse { .init(event: .deviceNotConnected) } + /// Shorthand property for a connected event. + static var deviceConnected: DeviceResponse { + .init(event: .deviceConnected) + } + /// Shorthand property for an unexpected socket event. static var unexpectedSocketEvent: DeviceResponse { .init(event: .unexpectedSocketEvent) diff --git a/Sources/App/API/Message+Extensions.swift b/Sources/App/API/Message+Extensions.swift deleted file mode 100644 index 38fb2b9..0000000 --- a/Sources/App/API/Message+Extensions.swift +++ /dev/null @@ -1,38 +0,0 @@ -import Foundation - -#if canImport(CryptoKit) -import CryptoKit -#else -import Crypto -#endif - -extension Message { - - static var length: Int { - SHA256.byteCount + Content.length - } - - init(decodeFrom data: T) where T.Element == UInt8 { - let count = SHA256.byteCount - self.mac = Data(data.prefix(count)) - self.content = .init(decodeFrom: Array(data.dropFirst(count))) - } - - func isValid(using key: SymmetricKey) -> Bool { - HMAC.isValidAuthenticationCode(mac, authenticating: content.encoded, using: key) - } -} - -extension Message.Content { - - 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 - } -} diff --git a/Sources/App/API/Message.swift b/Sources/App/API/Message.swift index 60ede14..b29024c 100644 --- a/Sources/App/API/Message.swift +++ b/Sources/App/API/Message.swift @@ -1,11 +1,36 @@ import Foundation import NIOCore +#if canImport(CryptoKit) +import CryptoKit +#else +import Crypto +#endif + /** An authenticated message to or from the device. */ struct Message: Equatable, Hashable { + /// The message authentication code for the message (32 bytes) + let mac: Data + + /// The message content + let content: Content + + /** + Create an authenticated message + - Parameter mac: The message authentication code + - Parameter content: The message content + */ + init(mac: Data, content: Content) { + self.mac = mac + self.content = content + } +} + +extension Message { + /** The message content without authentication. */ @@ -30,13 +55,13 @@ struct Message: Equatable, Hashable { /** Decode message content from data. - The data consists of two `UInt32` encoded in big endian format (MSB at index 0) + 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: T) where T.Element == UInt8 { - self.time = UInt32(data: data.prefix(MemoryLayout.size)) - self.id = UInt32(data: data.dropFirst(MemoryLayout.size)) + self.time = UInt32(data: Data(data.prefix(MemoryLayout.size))) + self.id = UInt32(data: Data(data.dropFirst(MemoryLayout.size))) } /// The byte length of an encoded message content @@ -48,27 +73,15 @@ struct Message: Equatable, Hashable { var encoded: Data { time.encoded + id.encoded } - - /// The message content encoded to bytes - var bytes: [UInt8] { - time.bytes + id.bytes - } } - /// The message authentication code for the message (32 bytes) - let mac: Data +} - /// The message content - let content: Content +extension Message { - /** - Create an authenticated message - - Parameter mac: The message authentication code - - Parameter content: The message content - */ - init(mac: Data, content: Content) { - self.mac = mac - self.content = content + /// The length of a message in bytes + static var length: Int { + SHA256.byteCount + Content.length } /** @@ -88,35 +101,51 @@ struct Message: Equatable, Hashable { mac + content.encoded } - /// The message encoded to bytes var bytes: [UInt8] { - Array(mac) + content.bytes + 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: T) where T.Element == UInt8 { + let count = SHA256.byteCount + self.mac = Data(data.prefix(count)) + self.content = .init(decodeFrom: Array(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.isValidAuthenticationCode(mac, authenticating: content.encoded, using: key) } } - extension UInt32 { +extension Message.Content { - /** - Create a value from a big-endian data representation (MSB first) - - Note: The data must contain exactly four bytes. - */ - init(data: T) where T.Element == UInt8 { - self = data - .reversed() - .enumerated() - .map { UInt32($0.element) << ($0.offset * 8) } - .reduce(0, +) - } + /** + 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) -> Message { + let mac = HMAC.authenticationCode(for: encoded, using: key) + return .init(mac: Data(mac.map { $0 }), content: self) + } - /// The value encoded to a big-endian representation - var encoded: Data { - .init(bytes) - } - - /// The value encoded to a big-endian byte array - var bytes: [UInt8] { - (0..<4).reversed().map { - UInt8((self >> ($0*8)) & 0xFF) - } - } + /** + 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.authenticationCode(for: encoded, using: key) + return Data(mac.map { $0 }) + encoded + } } diff --git a/Sources/App/API/MessageResult.swift b/Sources/App/API/MessageResult.swift index 384332f..ecf41af 100644 --- a/Sources/App/API/MessageResult.swift +++ b/Sources/App/API/MessageResult.swift @@ -38,6 +38,9 @@ enum MessageResult: UInt8 { /// Another message is being processed by the device case operationInProgress = 14 + + /// The device is connected + case deviceConnected = 15 } extension MessageResult: CustomStringConvertible { @@ -66,6 +69,8 @@ extension MessageResult: CustomStringConvertible { return "The device did not respond" case .operationInProgress: return "Another operation is in progress" + case .deviceConnected: + return "The device is connected" } } } diff --git a/Sources/App/API/ServerMessage.swift b/Sources/App/API/ServerMessage.swift new file mode 100644 index 0000000..9ee46f4 --- /dev/null +++ b/Sources/App/API/ServerMessage.swift @@ -0,0 +1,46 @@ +import Foundation +import NIOCore + +#if canImport(CryptoKit) +import CryptoKit +#else +import Crypto +#endif + +struct ServerMessage { + + static let authTokenSize = SHA256.byteCount + + static let length = authTokenSize + Message.length + + let authToken: Data + + let message: Message + + /** + 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 let data = buffer.getBytes(at: 0, length: ServerMessage.length) else { + return nil + } + self.authToken = Data(data.prefix(ServerMessage.authTokenSize)) + self.message = Message(decodeFrom: Data(data.dropFirst(ServerMessage.length))) + } + + var encoded: Data { + authToken + message.encoded + } + + 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/UInt32+Extensions.swift b/Sources/App/API/UInt32+Extensions.swift new file mode 100644 index 0000000..90708ef --- /dev/null +++ b/Sources/App/API/UInt32+Extensions.swift @@ -0,0 +1,18 @@ +import Foundation + +extension UInt32 { + + /** + Create a value from a little-endian data representation (MSB first) + - Note: The data must contain exactly four bytes. + */ + init(data: Data) { + let value = data.convert(into: UInt32.zero) + self = CFSwapInt32LittleToHost(value) + } + + /// The value encoded to a little-endian representation + var encoded: Data { + Data(from: CFSwapInt32HostToLittle(self)) + } +} diff --git a/Sources/App/DeviceManager.swift b/Sources/App/DeviceManager.swift index 7ff5887..acea6fa 100644 --- a/Sources/App/DeviceManager.swift +++ b/Sources/App/DeviceManager.swift @@ -10,6 +10,9 @@ final class DeviceManager { /// The authentication token of the device for the socket connection private let deviceKey: String + /// The authentication token of the remote + private let remoteKey: Data + /// Indicate that the socket is fully initialized with an authorized device var deviceIsAuthenticated = false @@ -23,8 +26,9 @@ final class DeviceManager { /// A promise to finish the request once the device responds or times out private var requestInProgress: EventLoopPromise? - init(deviceKey: String) { + init(deviceKey: String, remoteKey: Data) { self.deviceKey = deviceKey + self.remoteKey = remoteKey } // MARK: API @@ -64,6 +68,11 @@ final class DeviceManager { deviceIsAuthenticated = true } + func authenticateRemote(_ token: Data) -> Bool { + let hash = SHA256.hash(data: token) + return hash == remoteKey + } + func processDeviceResponse(_ data: ByteBuffer) { guard let promise = requestInProgress else { return diff --git a/Sources/App/configure.swift b/Sources/App/configure.swift index f0dcfe5..862fd3a 100755 --- a/Sources/App/configure.swift +++ b/Sources/App/configure.swift @@ -2,15 +2,32 @@ import Vapor var deviceManager: DeviceManager! +enum ServerError: Error { + case invalidAuthenticationFileContent + case invalidRemoteAuthenticationToken +} + // configures your application public func configure(_ app: Application) throws { app.http.server.configuration.port = Config.port let storageFolder = URL(fileURLWithPath: app.directory.resourcesDirectory) let keyFile = storageFolder.appendingPathComponent(Config.keyFileName) - let deviceKey = try String(contentsOf: keyFile) + let authContent = try String(contentsOf: keyFile) .trimmingCharacters(in: .whitespacesAndNewlines) - deviceManager = DeviceManager(deviceKey: deviceKey) + .components(separatedBy: "\n") + .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } + guard authContent.count == 2 else { + throw ServerError.invalidAuthenticationFileContent + } + let deviceKey = authContent[0] + guard let remoteKey = Data(fromHexEncodedString: authContent[1]) else { + throw ServerError.invalidRemoteAuthenticationToken + } + guard remoteKey.count == SHA256.byteCount else { + throw ServerError.invalidRemoteAuthenticationToken + } + deviceManager = DeviceManager(deviceKey: deviceKey, remoteKey: remoteKey) try routes(app) // Gracefully shut down by closing potentially open socket diff --git a/Sources/App/routes.swift b/Sources/App/routes.swift index 7d50c0e..9d16107 100755 --- a/Sources/App/routes.swift +++ b/Sources/App/routes.swift @@ -15,25 +15,51 @@ private func messageTransmission(_ req: Request) -> EventLoopFuture EventLoopFuture { + guard let body = req.body.data else { + return req.eventLoop.makeSucceededFuture(.noBodyData) + } + guard let authToken = ServerMessage.token(from: body) else { + return req.eventLoop.makeSucceededFuture(.invalidMessageData) + } + guard deviceManager.authenticateRemote(authToken) else { + return req.eventLoop.makeSucceededFuture(.invalidMessageData) + } + guard deviceManager.deviceIsConnected else { + return req.eventLoop.makeSucceededFuture(.deviceNotConnected) + } + return req.eventLoop.makeSucceededFuture(.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 response is a string of either "1" (connected) or "0" (disconnected) + The request returns one byte of data, which is the raw value of a `MessageResult`. + Possible results are `noBodyData`, `invalidMessageData`, `deviceNotConnected`, `deviceConnected`. */ - app.get(RouteAPI.getDeviceStatus.path) { req -> String in - deviceManager.deviceStatus + app.post(RouteAPI.getDeviceStatus.path) { req in + deviceStatus(req).map { + Response(status: .ok, body: .init(data: $0.encoded)) + } } /** 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 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`. diff --git a/Tests/AppTests/AppTests.swift b/Tests/AppTests/AppTests.swift index 2074e64..f352e9b 100644 --- a/Tests/AppTests/AppTests.swift +++ b/Tests/AppTests/AppTests.swift @@ -12,7 +12,7 @@ final class AppTests: XCTestCase { func testEncodingContent() { let input = Message.Content(time: 1234567890, id: 23456789) - let data = input.bytes + let data = Array(input.encoded) let output = Message.Content(decodeFrom: data) XCTAssertEqual(input, output) let data2 = [42, 42] + data