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

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 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<T: Sequence>(decodeFrom data: T) where T.Element == UInt8 {
self.time = UInt32(data: data.prefix(MemoryLayout<UInt32>.size))
self.id = UInt32(data: data.dropFirst(MemoryLayout<UInt32>.size))
self.time = UInt32(data: Data(data.prefix(MemoryLayout<UInt32>.size)))
self.id = UInt32(data: Data(data.dropFirst(MemoryLayout<UInt32>.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)
}
}
extension UInt32 {
/**
Create a value from a big-endian data representation (MSB first)
- Note: The data must contain exactly four bytes.
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>(data: T) where T.Element == UInt8 {
self = data
.reversed()
.enumerated()
.map { UInt32($0.element) << ($0.offset * 8) }
.reduce(0, +)
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)))
}
/// 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)
}
/**
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 Message.Content {
/**
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<SHA256>.authenticationCode(for: encoded, using: key)
return .init(mac: Data(mac.map { $0 }), content: self)
}
/**
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<SHA256>.authenticationCode(for: encoded, using: key)
return Data(mac.map { $0 }) + encoded
}
}

View File

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

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
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<DeviceResponse>?
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

View File

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

View File

@ -15,10 +15,29 @@ private func messageTransmission(_ req: Request) -> EventLoopFuture<DeviceRespon
guard let body = req.body.data else {
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 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 {
@ -26,15 +45,22 @@ func routes(_ app: Application) throws {
/**
Get the connection status of the device.
The response is a string of either "1" (connected) or "0" (disconnected)
The request expects the authentication token of the remote in the body data of the POST request.
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`.
*/

View File

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