Change to new message format

This commit is contained in:
Christoph Hagen 2022-04-07 23:53:25 +02:00
parent 9ab32cc758
commit f9039c7b3a
9 changed files with 288 additions and 112 deletions

View File

@ -1,10 +1,7 @@
import Foundation import Foundation
enum PublicAPI: String { enum PublicAPI: String {
case getDeviceResponse = "response"
case getDeviceStatus = "status" case getDeviceStatus = "status"
case clearKeyRequest = "clear" case postMessage = "message"
case postKey = "key"
case postKeyIdParameter = "id"
case socket = "listen" case socket = "listen"
} }

View File

@ -2,13 +2,7 @@ import Foundation
import WebSocketKit import WebSocketKit
import Vapor import Vapor
final class KeyManagement { final class DeviceManager {
/// 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
/// The seconds to wait for a response from the device /// The seconds to wait for a response from the device
static let deviceTimeout: Int64 = 20 static let deviceTimeout: Int64 = 20
@ -16,8 +10,10 @@ final class KeyManagement {
/// The connection to the device /// The connection to the device
private var connection: WebSocket? private var connection: WebSocket?
/// The authentication token of the device for the socket connection
private let deviceKey: String private let deviceKey: String
/// Indicate that the socket is fully initialized with an authorized device
var deviceIsAuthenticated = false var deviceIsAuthenticated = false
/// Indicator for device availability /// Indicator for device availability
@ -25,8 +21,8 @@ final class KeyManagement {
!(connection?.isClosed ?? true) && deviceIsAuthenticated !(connection?.isClosed ?? true) && deviceIsAuthenticated
} }
/// The id of the key which was sent to the device /// A promise to finish the request once the device responds or times out
private var keyInTransit: (id: UInt16, promise: EventLoopPromise<KeyResult>)? private var requestInProgress: EventLoopPromise<DeviceResponse>?
init(deviceKey: String) { init(deviceKey: String) {
self.deviceKey = deviceKey self.deviceKey = deviceKey
@ -38,26 +34,24 @@ final class KeyManagement {
deviceIsConnected ? "1" : "0" deviceIsConnected ? "1" : "0"
} }
func sendKeyToDevice(_ key: Data, keyId: UInt16, on eventLoop: EventLoop) -> EventLoopFuture<KeyResult> { func sendMessageToDevice(_ message: Message, on eventLoop: EventLoop) -> EventLoopFuture<DeviceResponse> {
guard key.count == KeyManagement.keySize else {
return eventLoop.makeSucceededFuture(.invalidPayloadSize)
}
guard let socket = connection, !socket.isClosed else { guard let socket = connection, !socket.isClosed else {
connection = nil connection = nil
return eventLoop.makeSucceededFuture(.deviceNotConnected) return eventLoop.makeSucceededFuture(.deviceNotConnected)
} }
let keyIdData = [UInt8(keyId >> 8), UInt8(keyId & 0xFF)] guard requestInProgress == nil else {
let promise = eventLoop.makePromise(of: KeyResult.self) return eventLoop.makeSucceededFuture(.operationInProgress)
keyInTransit = (keyId, promise) }
socket.send(keyIdData + key, promise: nil) requestInProgress = eventLoop.makePromise(of: DeviceResponse.self)
socket.send(message.bytes, promise: nil)
eventLoop.scheduleTask(in: .seconds(Self.deviceTimeout)) { [weak self] in 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 return
} }
self?.keyInTransit = nil self?.requestInProgress = nil
promise.succeed(.deviceTimedOut) promise.succeed(.deviceTimedOut)
} }
return promise.futureResult return requestInProgress!.futureResult
} }
func authenticateDevice(psk: String) { func authenticateDevice(psk: String) {
@ -72,27 +66,12 @@ final class KeyManagement {
} }
func processDeviceResponse(_ data: ByteBuffer) { func processDeviceResponse(_ data: ByteBuffer) {
guard let (_, promise) = keyInTransit else { guard let promise = requestInProgress else {
print("No key in transit for response from device \(data)") print("No message in transit for response from device \(data)")
return return
} }
defer { keyInTransit = nil } defer { requestInProgress = nil }
guard data.readableBytes == 1 else { promise.succeed(DeviceResponse(data) ?? .unexpectedSocketEvent)
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)
} }
func didCloseDeviceSocket() { func didCloseDeviceSocket() {

View File

@ -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
}
}

107
Sources/App/Message.swift Normal file
View File

@ -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..<data.startIndex+4])
self.id = UInt32(data: data[data.startIndex+4..<data.startIndex+8])
}
static var length: Int {
MemoryLayout<UInt32>.size * 2
}
func authenticate(using key: SymmetricKey) -> Message {
let mac = HMAC<SHA256>.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<SHA256>.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..<data.startIndex+count]
self.content = .init(decodeFrom: data.advanced(by: count))
}
func isValid(using key: SymmetricKey) -> Bool {
HMAC<SHA256>.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)
}
}
}

View File

@ -3,7 +3,7 @@ import Foundation
/** /**
A result from sending a key to the device. A result from sending a key to the device.
*/ */
enum KeyResult: UInt8 { enum MessageResult: UInt8 {
/// Text content was received, although binary data was expected /// Text content was received, although binary data was expected
case textReceived = 1 case textReceived = 1
@ -11,23 +11,21 @@ enum KeyResult: UInt8 {
/// A socket event on the device was unexpected (not binary data) /// A socket event on the device was unexpected (not binary data)
case unexpectedSocketEvent = 2 case unexpectedSocketEvent = 2
/// The size of the payload (key id + key data, or just key) was invalid /// The size of the payload (i.e. message) was invalid, or the data could not be read
case invalidPayloadSize = 3 case invalidMessageData = 3
/// The index of the key was out of bounds /// The transmitted message could not be authenticated using the key
case invalidKeyIndex = 4 case messageAuthenticationFailed = 4
/// The transmitted key data did not match the expected key /// The message time was not within the acceptable bounds
case invalidKey = 5 case messageTimeMismatch = 5
/// The key has been previously used and is no longer valid
case keyAlreadyUsed = 6
/// A later key has been used, invalidating this key (to prevent replay attacks after blocked communication) /// 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 /// 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 /// The device produced an unknown error
case unknownDeviceError = 9 case unknownDeviceError = 9
@ -43,38 +41,41 @@ enum KeyResult: UInt8 {
/// The device did not respond within the timeout /// The device did not respond within the timeout
case deviceTimedOut = 13 case deviceTimedOut = 13
/// Another message is being processed by the device
case operationInProgress = 14
} }
extension KeyResult: CustomStringConvertible { extension MessageResult: CustomStringConvertible {
var description: String { var description: String {
switch self { 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: 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 .invalidKey: case .invalidMessageData:
return "The transmitted key was not correct" return "Invalid message data"
case .keyAlreadyUsed: case .messageAuthenticationFailed:
return "The transmitted key was already used" return "Message authentication failed"
case .keyWasSkipped: case .messageTimeMismatch:
return "A newer key was already used" return "Message time invalid"
case .keyAccepted: case .messageCounterInvalid:
return "Key successfully sent" return "Message counter invalid"
case .messageAccepted:
return "Message accepted"
case .unknownDeviceError: case .unknownDeviceError:
return "The device experienced an unknown error" 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: case .deviceTimedOut:
return "The device did not respond" return "The device did not respond"
case .operationInProgress:
return "Another operation is in progress"
} }
} }
} }

View File

@ -1,22 +1,22 @@
import Vapor import Vapor
var keyManager: KeyManagement! var deviceManager: DeviceManager!
// configures your application // configures your application
public func configure(_ app: Application) throws { public func configure(_ app: Application) throws {
app.http.server.configuration.port = 6003 app.http.server.configuration.port = 6003
let storageFolder = URL(fileURLWithPath: app.directory.resourcesDirectory) let storageFolder = URL(fileURLWithPath: app.directory.resourcesDirectory)
let keyFile = storageFolder.appendingPathComponent("device.key") let keyFile = storageFolder.appendingPathComponent("deviceKey")
let deviceKey = try String(contentsOf: keyFile) let deviceKey = try String(contentsOf: keyFile)
.trimmingCharacters(in: .whitespacesAndNewlines) .trimmingCharacters(in: .whitespacesAndNewlines)
keyManager = KeyManagement(deviceKey: deviceKey) deviceManager = DeviceManager(deviceKey: deviceKey)
try routes(app) try routes(app)
// Gracefully shut down by closing potentially open socket // Gracefully shut down by closing potentially open socket
DispatchQueue.global(qos: .utility).asyncAfter(deadline: .now() + .seconds(5)) { DispatchQueue.global(qos: .utility).asyncAfter(deadline: .now() + .seconds(5)) {
_ = app.server.onShutdown.always { _ in _ = app.server.onShutdown.always { _ in
keyManager.removeDeviceConnection() deviceManager.removeDeviceConnection()
} }
} }
} }

View File

@ -11,20 +11,14 @@ extension PublicAPI {
} }
} }
private func keyTransmission(_ req: Request) -> EventLoopFuture<KeyResult> { private func messageTransmission(_ req: Request) -> EventLoopFuture<DeviceResponse> {
guard let keyId = req.parameters.get(PublicAPI.postKeyIdParameter.rawValue, as: UInt16.self) else {
return req.eventLoop.makeSucceededFuture(.invalidKeyIndex)
}
guard let body = req.body.data else { guard let body = req.body.data else {
return req.eventLoop.makeSucceededFuture(.noBodyData) return req.eventLoop.makeSucceededFuture(.noBodyData)
} }
guard body.readableBytes == KeyManagement.keySize else { guard let message = Message(decodeFrom: body) else {
return req.eventLoop.makeSucceededFuture(.invalidPayloadSize) return req.eventLoop.makeSucceededFuture(.invalidMessageData)
} }
guard let key = body.getData(at: 0, length: KeyManagement.keySize) else { return deviceManager.sendMessageToDevice(message, on: req.eventLoop)
return req.eventLoop.makeSucceededFuture(.corruptkeyData)
}
return keyManager.sendKeyToDevice(key, keyId: keyId, on: req.eventLoop)
} }
func routes(_ app: Application) throws { 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) The response is a string of either "1" (connected) or "0" (disconnected)
*/ */
app.get(PublicAPI.getDeviceStatus.path) { req -> String in app.get(PublicAPI.getDeviceStatus.path) { req -> String in
keyManager.deviceStatus deviceManager.deviceStatus
} }
/** /**
Post a key to the device for unlocking. 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 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 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.
*/ */
app.post(PublicAPI.postKey.path, PublicAPI.postKeyIdParameter.pathParameter) { req in app.post(PublicAPI.postMessage.path) { req in
keyTransmission(req).map { String($0.rawValue) } 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 app.webSocket(PublicAPI.socket.path) { req, socket in
socket.onBinary { _, data in socket.onBinary { _, data in
keyManager.processDeviceResponse(data) deviceManager.processDeviceResponse(data)
} }
socket.onText { _, text in socket.onText { _, text in
keyManager.authenticateDevice(psk: text) deviceManager.authenticateDevice(psk: text)
} }
_ = socket.onClose.always { _ in _ = socket.onClose.always { _ in
keyManager.didCloseDeviceSocket() deviceManager.didCloseDeviceSocket()
} }
keyManager.createNewDeviceConnection(socket) deviceManager.createNewDeviceConnection(socket)
} }
} }

View File

@ -2,14 +2,44 @@
import XCTVapor import XCTVapor
final class AppTests: XCTestCase { 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 func testEncodingUInt32() {
XCTAssertEqual(res.status, .ok) let input: UInt32 = 123
XCTAssertEqual(res.body.string, "Hello, world!") 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)
} }
} }