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
enum PublicAPI: String {
case getDeviceResponse = "response"
case getDeviceStatus = "status"
case clearKeyRequest = "clear"
case postKey = "key"
case postKeyIdParameter = "id"
case postMessage = "message"
case socket = "listen"
}

View File

@ -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<KeyResult>)?
/// A promise to finish the request once the device responds or times out
private var requestInProgress: EventLoopPromise<DeviceResponse>?
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<KeyResult> {
guard key.count == KeyManagement.keySize else {
return eventLoop.makeSucceededFuture(.invalidPayloadSize)
}
func sendMessageToDevice(_ message: Message, on eventLoop: EventLoop) -> EventLoopFuture<DeviceResponse> {
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() {

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.
*/
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"
}
}
}

View File

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

View File

@ -11,20 +11,14 @@ extension PublicAPI {
}
}
private func keyTransmission(_ req: Request) -> EventLoopFuture<KeyResult> {
guard let keyId = req.parameters.get(PublicAPI.postKeyIdParameter.rawValue, as: UInt16.self) else {
return req.eventLoop.makeSucceededFuture(.invalidKeyIndex)
}
private func messageTransmission(_ req: Request) -> EventLoopFuture<DeviceResponse> {
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)
}
}

View File

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