Add authentication for remote
This commit is contained in:
parent
e6fc0308ed
commit
aa0646ba87
@ -1 +0,0 @@
|
||||
access token
|
2
Resources/keys
Normal file
2
Resources/keys
Normal file
@ -0,0 +1,2 @@
|
||||
access token
|
||||
0000000000000000000000000000000000000000000000000000000000000000
|
59
Sources/App/API/Data+Extensions.swift
Normal file
59
Sources/App/API/Data+Extensions.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
@ -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)
|
||||
|
@ -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
|
||||
}
|
||||
}
|
@ -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
|
||||
}
|
||||
}
|
||||
|
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
46
Sources/App/API/ServerMessage.swift
Normal file
46
Sources/App/API/ServerMessage.swift
Normal 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)
|
||||
}
|
||||
}
|
18
Sources/App/API/UInt32+Extensions.swift
Normal file
18
Sources/App/API/UInt32+Extensions.swift
Normal 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))
|
||||
}
|
||||
}
|
@ -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
|
||||
|
@ -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
|
||||
|
@ -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`.
|
||||
*/
|
||||
|
@ -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
|
||||
|
Loading…
Reference in New Issue
Block a user