diff --git a/README.md b/README.md index 6a896e0..9f53cb2 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,36 @@ # Sesame-Server -Server code \ No newline at end of file +Server code for the `Sesame`project. The server acts as a relay for messages between the device (ESP32) and the client (iOS App), by providing a DNS entry to which both devices can reliably connect. +The device is permanently connected to the server through a web socket, so that the server can push new requests to the device. The client performs simple POST and GET requests to the server to upload keys and retrieve device responses. + +## Operation + +The server provides APIs for the device and the iOS client. The API for the device consists of a single entry point to establish a web socket connection for bidirectional communication. The device authenticates with a pre-shared key to prevent other actors from replacing the device. + +The client API has multiple routes to inquire about device status and reponses, and to send a key to the device. + +### Access + +The server is configured to listen on port 10000. + +## Security + +The system does not rely on any cryptographic algorithms, which are difficult to execute and verify on resource-constrained devices. The system relies instead on pre-shared one-time keys, which are hardcoded into the device when flashing the software. The same codes are stored within the iOS App. + +### Protection against attacks + +One-time keys are only vulnerable to attacks where they are somehow observed prior to use. Within this system, this can occur on the embedded device, the client App, the programming hardware, and during transmission of the codes over the network. + +The device itself is secured against physical attacks by being inside the house which it opens. An attacker with physical access to the device has already succeeded in bypassing the system. + +The iOS device is secured by the mechanisms deployed by Apple against unwanted access to app data. These protections are deemed sufficient for this application, and would be difficult to further improve within this project. + +The programming hardware is also protected by similar mechanisms, and the codes only reside on this system when initially programming the device, and are afterwards securely deleted. + +The one-time codes are vulnerable when being transmitted over the network, including being observed or tampered with on the server. To prevent this, each key is associated with an id, and keys are expected to be used in the correct sequence. Using a key with a higher id invalidates all keys with lower ids. This prevents cases where an attacker blocks the use of a key to use it at a later time. + +For the case where an attacker blocks all keys from being used, then all keys must be invalidated manually be uploading new keys to the device. + +The device itself may be vulnerable to attacks due to errors in the software stack, including WiFi and other features. The device is isolated from the global network through the routers firewall, and only exposes the web socket connection, which is secured by SSL. + + diff --git a/Resources/device.key b/Resources/device.key new file mode 100644 index 0000000..6dae4c1 --- /dev/null +++ b/Resources/device.key @@ -0,0 +1 @@ +access token diff --git a/Sources/App/API.swift b/Sources/App/API.swift new file mode 100644 index 0000000..1810f62 --- /dev/null +++ b/Sources/App/API.swift @@ -0,0 +1,10 @@ +import Foundation + +enum PublicAPI: String { + case getDeviceResponse = "response" + case getDeviceStatus = "status" + case clearKeyRequest = "clear" + case postKey = "key" + case postKeyIdParameter = "id" + case socket = "listen" +} diff --git a/Sources/App/KeyManagement.swift b/Sources/App/KeyManagement.swift new file mode 100644 index 0000000..2ccf30d --- /dev/null +++ b/Sources/App/KeyManagement.swift @@ -0,0 +1,124 @@ +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 + + /// The connection to the device + private var connection: WebSocket? + + private let deviceKey: String + + var deviceIsAuthenticated = false + + /// Indicator for device availability + var deviceIsConnected: Bool { + !(connection?.isClosed ?? true) && deviceIsAuthenticated + } + + /// The id of the key which was sent to the device + private var keyInTransit: UInt16? + + /// The result transmitted by the device for the sent key + var keyResult: KeyResult = .none + + init(deviceKey: String) { + self.deviceKey = deviceKey + } + + // MARK: API + + var deviceResponse: String { + guard let keyId = keyInTransit else { + return "No key" + } + return "\(keyId):\(keyResult.rawValue)" + } + + var deviceStatus: String { + deviceIsConnected ? "1" : "0" + } + + func clearClientRequest() { + keyInTransit = nil + keyResult = .none + } + + func sendKeyToDevice(_ key: Data, keyId: UInt16) -> KeyPostResponse { + guard key.count == KeyManagement.keySize else { + return .invalidKeySize + } + guard let socket = connection, !socket.isClosed else { + connection = nil + return .deviceNotConnected + } + let keyIdData = [UInt8(keyId >> 8), UInt8(keyId & 0xFF)] + keyInTransit = keyId + socket.send(keyIdData + key, promise: nil) + return .success + } + + func authenticateDevice(psk: String) { + guard psk == self.deviceKey else { + print("Invalid device key") + _ = connection?.close() + deviceIsAuthenticated = false + return + } + print("Device authenticated") + deviceIsAuthenticated = true + } + + func processDeviceResponse(_ data: ByteBuffer) { + guard data.readableBytes == 1 else { + print("Unexpected number of bytes received from device") + keyInTransit = nil + keyResult = .unexpectedSocketEvent + return + } + guard let rawValue = data.getBytes(at: 0, length: 1)?.first else { + print("Unreadable data received from device") + keyInTransit = nil + keyResult = .unexpectedSocketEvent + return + } + guard let response = KeyResult(rawValue: rawValue) else { + print("Unknown response \(rawValue) received from device") + keyInTransit = nil + keyResult = .unexpectedSocketEvent + return + } + guard keyInTransit != nil else { + print("No key in transit for response \(response)") + return + } + keyResult = response + } + + func didCloseDeviceSocket() { + deviceIsAuthenticated = false + guard connection != nil else { + return + } + connection = nil + print("Socket closed") + } + + func removeDeviceConnection() { + _ = connection?.close() + connection = nil + } + + func createNewDeviceConnection(_ socket: WebSocket) { + removeDeviceConnection() + connection = socket + deviceIsAuthenticated = false + print("Socket connected") + } +} diff --git a/Sources/App/Response.swift b/Sources/App/Response.swift new file mode 100644 index 0000000..0823c18 --- /dev/null +++ b/Sources/App/Response.swift @@ -0,0 +1,64 @@ +import Foundation + +/** + A result from sending a key to the device. + */ +enum KeyResult: UInt8 { + + /// No result from the device, or state not applicable + case none = 0 + + /// Text content was received, although binary data was expected + case textReceived = 1 + + /// A socket event on the device was unexpected (not binary data) + case unexpectedSocketEvent = 2 + + /// The size of the payload (key id + key data) was invalid + case invalidPayloadSize = 3 + + /// 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 + + /// A later key has been used, invalidating this key (to prevent replay attacks after blocked communication) + case keyWasSkipped = 7 + + /// The key was accepted by the device, and the door will be opened + case keyAccepted = 8 + + /// The device produced an unknown error + case uknownDeviceError = 9 + + /// The device is not connected through the socket + case notConnected = 10 +} + +/** + A response from the server to a key request. + */ +enum KeyPostResponse: Int { + + /// The key will be transmitted to the device + case success = 0 + + /// The key id is out of bounds or otherwise invalid + case invalidKeyId = 1 + + /// The request did not contain body data with the key + case noBodyData = 2 + + /// The key contained in the body data has an invalid size + case invalidKeySize = 3 + + /// The body data could not be read + case corruptkeyData = 4 + + /// The device is not connected + case deviceNotConnected = 5 +} diff --git a/Sources/App/configure.swift b/Sources/App/configure.swift index 52fcba5..90ed126 100755 --- a/Sources/App/configure.swift +++ b/Sources/App/configure.swift @@ -1,7 +1,22 @@ import Vapor +var keyManager: KeyManagement! + // configures your application public func configure(_ app: Application) throws { app.http.server.configuration.port = 10000 + + let storageFolder = URL(fileURLWithPath: app.directory.resourcesDirectory) + let keyFile = storageFolder.appendingPathComponent("device.key") + let deviceKey = try String(contentsOf: keyFile) + .trimmingCharacters(in: .whitespacesAndNewlines) + keyManager = KeyManagement(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() + } + } } diff --git a/Sources/App/routes.swift b/Sources/App/routes.swift index adcdf89..0d40d2c 100755 --- a/Sources/App/routes.swift +++ b/Sources/App/routes.swift @@ -1,11 +1,74 @@ import Vapor -var connection: WebSocket? +extension PublicAPI { + + var path: PathComponent { + .init(stringLiteral: rawValue) + } + + var pathParameter: PathComponent { + .parameter(rawValue) + } +} + +private func handleKeyPost(_ req: Request) -> KeyPostResponse { + guard let keyId = req.parameters.get(PublicAPI.postKeyIdParameter.rawValue, as: UInt16.self) else { + return .invalidKeyId + } + guard let body = req.body.data else { + return .noBodyData + } + guard body.readableBytes == KeyManagement.keySize else { + return .invalidKeySize + } + guard let key = body.getData(at: 0, length: KeyManagement.keySize) else { + return .corruptkeyData + } + return keyManager.sendKeyToDevice(key, keyId: keyId) +} func routes(_ app: Application) throws { - app.get { req in - return "It works!" + /** + Get the connection status of the device. + + The response is a string of either "1" (connected) or "0" (disconnected) + */ + app.get(PublicAPI.getDeviceStatus.path) { req -> String in + keyManager.deviceStatus + } + + /** + Get the response from the device. + + The response is a string of an integer `rawValue` of a `KeyResult` + */ + app.get(PublicAPI.getDeviceResponse.path) { req -> String in + keyManager.deviceResponse + } + + /** + Post a request to remove the information about the last key transmission. + + - The request always succeeds and returns the string "Success" + */ + app.post(PublicAPI.clearKeyRequest.path) { req -> String in + keyManager.clearClientRequest() + return "Success" + } + + /** + 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. + */ + app.post(PublicAPI.postKey.path, PublicAPI.postKeyIdParameter.pathParameter) { req -> String in + let result = handleKeyPost(req) + return String(result.rawValue) } /** @@ -13,19 +76,17 @@ func routes(_ app: Application) throws { - Returns: Nothing - Note: The first (and only) message from the client over the connection must be a valid session token. */ - app.webSocket("listen") { req, socket in - socket.onBinary { socket, data in - print("\(data)") + app.webSocket(PublicAPI.socket.path) { req, socket in + socket.onBinary { _, data in + keyManager.processDeviceResponse(data) } - socket.onText { socket, text in - print(text) + socket.onText { _, text in + keyManager.authenticateDevice(psk: text) } - _ = socket.onClose.always { result in - connection = nil - print("Socket closed") + _ = socket.onClose.always { _ in + keyManager.didCloseDeviceSocket() } - connection = socket - print("Socket connected") + keyManager.createNewDeviceConnection(socket) } }