Add authentication for remote

This commit is contained in:
Christoph Hagen 2022-05-01 13:12:16 +02:00
parent e6fc0308ed
commit aa0646ba87
13 changed files with 270 additions and 93 deletions

View File

@ -1 +0,0 @@
access token

2
Resources/keys Normal file
View File

@ -0,0 +1,2 @@
access token
0000000000000000000000000000000000000000000000000000000000000000

View File

@ -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<T>(into value: T) -> T {
withUnsafeBytes {
$0.baseAddress!.load(as: T.self)
}
}
init<T>(from value: T) {
var target = value
self = Swift.withUnsafeBytes(of: &target) {
Data($0)
}
}
}

View File

@ -16,6 +16,11 @@ struct DeviceResponse {
.init(event: .deviceNotConnected) .init(event: .deviceNotConnected)
} }
/// Shorthand property for a connected event.
static var deviceConnected: DeviceResponse {
.init(event: .deviceConnected)
}
/// Shorthand property for an unexpected socket event. /// Shorthand property for an unexpected socket event.
static var unexpectedSocketEvent: DeviceResponse { static var unexpectedSocketEvent: DeviceResponse {
.init(event: .unexpectedSocketEvent) .init(event: .unexpectedSocketEvent)

View File

@ -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<T: Sequence>(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<SHA256>.isValidAuthenticationCode(mac, authenticating: content.encoded, using: key)
}
}
extension Message.Content {
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
}
}

View File

@ -1,11 +1,36 @@
import Foundation import Foundation
import NIOCore import NIOCore
#if canImport(CryptoKit)
import CryptoKit
#else
import Crypto
#endif
/** /**
An authenticated message to or from the device. An authenticated message to or from the device.
*/ */
struct Message: Equatable, Hashable { 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. The message content without authentication.
*/ */
@ -30,13 +55,13 @@ struct Message: Equatable, Hashable {
/** /**
Decode message content from data. 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. - Warning: The sequence must contain at least 8 bytes, or the function will crash.
- Parameter data: The sequence containing the bytes. - Parameter data: The sequence containing the bytes.
*/ */
init<T: Sequence>(decodeFrom data: T) where T.Element == UInt8 { init<T: Sequence>(decodeFrom data: T) where T.Element == UInt8 {
self.time = UInt32(data: data.prefix(MemoryLayout<UInt32>.size)) self.time = UInt32(data: Data(data.prefix(MemoryLayout<UInt32>.size)))
self.id = UInt32(data: data.dropFirst(MemoryLayout<UInt32>.size)) self.id = UInt32(data: Data(data.dropFirst(MemoryLayout<UInt32>.size)))
} }
/// The byte length of an encoded message content /// The byte length of an encoded message content
@ -48,27 +73,15 @@ struct Message: Equatable, Hashable {
var encoded: Data { var encoded: Data {
time.encoded + id.encoded 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 extension Message {
let content: Content
/** /// The length of a message in bytes
Create an authenticated message static var length: Int {
- Parameter mac: The message authentication code SHA256.byteCount + Content.length
- Parameter content: The message content
*/
init(mac: Data, content: Content) {
self.mac = mac
self.content = content
} }
/** /**
@ -88,35 +101,51 @@ struct Message: Equatable, Hashable {
mac + content.encoded mac + content.encoded
} }
/// The message encoded to bytes
var bytes: [UInt8] { 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<T: Sequence>(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<SHA256>.isValidAuthenticationCode(mac, authenticating: content.encoded, using: key)
} }
} }
extension UInt32 { extension Message.Content {
/** /**
Create a value from a big-endian data representation (MSB first) Calculate an authentication code for the message content.
- Note: The data must contain exactly four bytes. - Parameter key: The key to use to sign the content.
*/ - Returns: The new message signed with the key.
init<T: Sequence>(data: T) where T.Element == UInt8 { */
self = data func authenticate(using key: SymmetricKey) -> Message {
.reversed() let mac = HMAC<SHA256>.authenticationCode(for: encoded, using: key)
.enumerated() return .init(mac: Data(mac.map { $0 }), content: self)
.map { UInt32($0.element) << ($0.offset * 8) } }
.reduce(0, +)
}
/// The value encoded to a big-endian representation /**
var encoded: Data { Calculate an authentication code for the message content and convert everything to data.
.init(bytes) - Parameter key: The key to use to sign the content.
} - Returns: The new message signed with the key, serialized to bytes.
*/
/// The value encoded to a big-endian byte array func authenticateAndSerialize(using key: SymmetricKey) -> Data {
var bytes: [UInt8] { let encoded = self.encoded
(0..<4).reversed().map { let mac = HMAC<SHA256>.authenticationCode(for: encoded, using: key)
UInt8((self >> ($0*8)) & 0xFF) return Data(mac.map { $0 }) + encoded
} }
}
} }

View File

@ -38,6 +38,9 @@ enum MessageResult: UInt8 {
/// Another message is being processed by the device /// Another message is being processed by the device
case operationInProgress = 14 case operationInProgress = 14
/// The device is connected
case deviceConnected = 15
} }
extension MessageResult: CustomStringConvertible { extension MessageResult: CustomStringConvertible {
@ -66,6 +69,8 @@ extension MessageResult: CustomStringConvertible {
return "The device did not respond" return "The device did not respond"
case .operationInProgress: case .operationInProgress:
return "Another operation is in progress" return "Another operation is in progress"
case .deviceConnected:
return "The device is connected"
} }
} }
} }

View File

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

View File

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

View File

@ -10,6 +10,9 @@ final class DeviceManager {
/// The authentication token of the device for the socket connection /// The authentication token of the device for the socket connection
private let deviceKey: String 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 /// Indicate that the socket is fully initialized with an authorized device
var deviceIsAuthenticated = false var deviceIsAuthenticated = false
@ -23,8 +26,9 @@ final class DeviceManager {
/// A promise to finish the request once the device responds or times out /// A promise to finish the request once the device responds or times out
private var requestInProgress: EventLoopPromise<DeviceResponse>? private var requestInProgress: EventLoopPromise<DeviceResponse>?
init(deviceKey: String) { init(deviceKey: String, remoteKey: Data) {
self.deviceKey = deviceKey self.deviceKey = deviceKey
self.remoteKey = remoteKey
} }
// MARK: API // MARK: API
@ -64,6 +68,11 @@ final class DeviceManager {
deviceIsAuthenticated = true deviceIsAuthenticated = true
} }
func authenticateRemote(_ token: Data) -> Bool {
let hash = SHA256.hash(data: token)
return hash == remoteKey
}
func processDeviceResponse(_ data: ByteBuffer) { func processDeviceResponse(_ data: ByteBuffer) {
guard let promise = requestInProgress else { guard let promise = requestInProgress else {
return return

View File

@ -2,15 +2,32 @@ import Vapor
var deviceManager: DeviceManager! var deviceManager: DeviceManager!
enum ServerError: Error {
case invalidAuthenticationFileContent
case invalidRemoteAuthenticationToken
}
// configures your application // configures your application
public func configure(_ app: Application) throws { public func configure(_ app: Application) throws {
app.http.server.configuration.port = Config.port app.http.server.configuration.port = Config.port
let storageFolder = URL(fileURLWithPath: app.directory.resourcesDirectory) let storageFolder = URL(fileURLWithPath: app.directory.resourcesDirectory)
let keyFile = storageFolder.appendingPathComponent(Config.keyFileName) let keyFile = storageFolder.appendingPathComponent(Config.keyFileName)
let deviceKey = try String(contentsOf: keyFile) let authContent = try String(contentsOf: keyFile)
.trimmingCharacters(in: .whitespacesAndNewlines) .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) try routes(app)
// Gracefully shut down by closing potentially open socket // Gracefully shut down by closing potentially open socket

View File

@ -15,25 +15,51 @@ private func messageTransmission(_ req: Request) -> EventLoopFuture<DeviceRespon
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 let message = Message(decodeFrom: body) else { guard let message = ServerMessage(decodeFrom: body) else {
return req.eventLoop.makeSucceededFuture(.invalidMessageData) return req.eventLoop.makeSucceededFuture(.invalidMessageData)
} }
return deviceManager.sendMessageToDevice(message, on: req.eventLoop) guard deviceManager.authenticateRemote(message.authToken) else {
return req.eventLoop.makeSucceededFuture(.invalidMessageData)
}
return deviceManager.sendMessageToDevice(message.message, on: req.eventLoop)
}
private func deviceStatus(_ req: Request) -> EventLoopFuture<DeviceResponse> {
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 { func routes(_ app: Application) throws {
/** /**
Get the connection status of the device. 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 app.post(RouteAPI.getDeviceStatus.path) { req in
deviceManager.deviceStatus deviceStatus(req).map {
Response(status: .ok, body: .init(data: $0.encoded))
}
} }
/** /**
Post a message to the device for unlocking. 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`, 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`. 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`.

View File

@ -12,7 +12,7 @@ final class AppTests: XCTestCase {
func testEncodingContent() { func testEncodingContent() {
let input = Message.Content(time: 1234567890, id: 23456789) let input = Message.Content(time: 1234567890, id: 23456789)
let data = input.bytes let data = Array(input.encoded)
let output = Message.Content(decodeFrom: data) let output = Message.Content(decodeFrom: data)
XCTAssertEqual(input, output) XCTAssertEqual(input, output)
let data2 = [42, 42] + data let data2 = [42, 42] + data