Compare commits
No commits in common. "1fe03a6906c8233cedc00fe9f763d2185d74cc87" and "69a8f32179c93d6f5dccac153d498e709daffcd1" have entirely different histories.
1fe03a6906
...
69a8f32179
@ -3,34 +3,25 @@
|
|||||||
#include "server.h"
|
#include "server.h"
|
||||||
#include "servo.h"
|
#include "servo.h"
|
||||||
#include "message.h"
|
#include "message.h"
|
||||||
|
#include "storage.h"
|
||||||
|
#include "fresh.h"
|
||||||
#include <ESPAsyncWebServer.h>
|
#include <ESPAsyncWebServer.h>
|
||||||
|
|
||||||
struct EthernetConfiguration {
|
struct WifiConfiguration {
|
||||||
|
|
||||||
// The MAC address of the ethernet connection
|
// The WiFi network to connect to
|
||||||
uint8_t macAddress[6];
|
const char* ssid;
|
||||||
|
|
||||||
// The master-in slave-out pin of the SPI connection for the Ethernet module
|
// The WiFi password to connect to the above network
|
||||||
int8_t spiPinMiso;
|
const char* password;
|
||||||
|
|
||||||
// The master-out slave-in pin of the SPI connection for the Ethernet module
|
// The name of the device on the network
|
||||||
int8_t spiPinMosi;
|
const char* networkName;
|
||||||
|
|
||||||
// The slave clock pin of the SPI connection for the Ethernet module
|
// The interval to reconnect to WiFi if the connection is broken
|
||||||
int8_t spiPinSclk;
|
uint32_t reconnectInterval;
|
||||||
|
|
||||||
// The slave-select pin of the SPI connection for the Ethernet module
|
|
||||||
int8_t spiPinSS;
|
|
||||||
|
|
||||||
unsigned long dhcpLeaseTimeoutMs;
|
|
||||||
unsigned long dhcpLeaseResponseTimeoutMs;
|
|
||||||
|
|
||||||
// The static IP address to assign if DHCP fails
|
|
||||||
uint8_t manualIp[4];
|
|
||||||
|
|
||||||
// The IP address of the DNS server, if DHCP fails
|
|
||||||
uint8_t manualDnsAddress[4];
|
|
||||||
|
|
||||||
|
uint32_t periodicReconnectInterval;
|
||||||
};
|
};
|
||||||
|
|
||||||
struct KeyConfiguration {
|
struct KeyConfiguration {
|
||||||
@ -38,133 +29,55 @@ struct KeyConfiguration {
|
|||||||
const uint8_t* remoteKey;
|
const uint8_t* remoteKey;
|
||||||
|
|
||||||
const uint8_t* localKey;
|
const uint8_t* localKey;
|
||||||
|
|
||||||
uint32_t challengeExpiryMs;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
class SesameController: public ServerConnectionCallbacks {
|
class SesameController: public ServerConnectionCallbacks {
|
||||||
|
|
||||||
public:
|
public:
|
||||||
SesameController(uint16_t localWebServerPort);
|
SesameController(uint16_t localWebServerPort, uint8_t remoteDeviceCount);
|
||||||
|
|
||||||
void configure(ServoConfiguration servoConfig, ServerConfiguration serverConfig, EthernetConfiguration ethernetConfig, KeyConfiguration keyConfig);
|
void configure(ServoConfiguration servoConfig, ServerConfiguration serverConfig, TimeConfiguration timeConfig, WifiConfiguration wifiConfig, KeyConfiguration keyConfig);
|
||||||
|
|
||||||
void loop(uint32_t millis);
|
void loop(uint32_t millis);
|
||||||
|
|
||||||
private:
|
private:
|
||||||
|
|
||||||
uint32_t currentTime = 0;
|
|
||||||
|
|
||||||
ServerConnection server;
|
ServerConnection server;
|
||||||
ServoController servo;
|
ServoController servo;
|
||||||
AsyncWebServer localWebServer;
|
AsyncWebServer localWebServer;
|
||||||
|
TimeCheck timeCheck;
|
||||||
|
Storage storage;
|
||||||
|
|
||||||
EthernetConfiguration ethernetConfig;
|
WifiConfiguration wifiConfig;
|
||||||
bool ethernetIsConfigured = false;
|
|
||||||
|
|
||||||
KeyConfiguration keyConfig;
|
KeyConfiguration keyConfig;
|
||||||
|
|
||||||
bool isReconnecting = false;
|
bool isReconnecting = false;
|
||||||
|
|
||||||
// Buffer to get local message
|
// The buffer to hold a received message while it is read
|
||||||
SignedMessage receivedLocalMessage;
|
uint8_t receivedMessageBuffer[AUTHENTICATED_MESSAGE_SIZE];
|
||||||
|
|
||||||
uint32_t currentClientChallenge;
|
// The buffer to hold a response while it is sent
|
||||||
uint32_t currentChallengeExpiry = 0;
|
uint8_t responseBuffer[AUTHENTICATED_MESSAGE_SIZE+1];
|
||||||
uint32_t currentServerChallenge;
|
SesameEvent* responseStatus;
|
||||||
|
AuthenticatedMessage* responseMessage;
|
||||||
|
uint16_t responseSize = 0;
|
||||||
|
|
||||||
SignedMessage outgoingMessage;
|
void ensureWiFiConnection(uint32_t time);
|
||||||
|
void ensureWebSocketConnection();
|
||||||
bool hasCurrentChallenge() {
|
|
||||||
return currentChallengeExpiry > currentTime;
|
|
||||||
}
|
|
||||||
|
|
||||||
void clearCurrentChallenge() {
|
|
||||||
currentClientChallenge = 0;
|
|
||||||
currentServerChallenge = 0;
|
|
||||||
currentChallengeExpiry = 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: Local client callbacks
|
|
||||||
|
|
||||||
void handleLocalMessage(AsyncWebServerRequest *request);
|
void handleLocalMessage(AsyncWebServerRequest *request);
|
||||||
|
// Based on https://stackoverflow.com/a/23898449/266720
|
||||||
// MARK: Socket Callbacks
|
bool convertHexMessageToBinary(const char* str);
|
||||||
|
|
||||||
/**
|
|
||||||
* @brief Callback to send an error back to the server via the web socket.
|
|
||||||
*
|
|
||||||
* This function is called when the socket get's an error.
|
|
||||||
*
|
|
||||||
* @param event The error to report back
|
|
||||||
*/
|
|
||||||
void sendServerError(MessageResult event);
|
|
||||||
|
|
||||||
void handleServerMessage(uint8_t* payload, size_t length);
|
void handleServerMessage(uint8_t* payload, size_t length);
|
||||||
|
void sendServerError(SesameEvent event);
|
||||||
|
|
||||||
// MARK: Message processing
|
void processMessage(AuthenticatedMessage* message);
|
||||||
|
SesameEvent verifyAndProcessReceivedMessage(AuthenticatedMessage* message);
|
||||||
|
|
||||||
/**
|
void prepareResponseBuffer(SesameEvent event, uint8_t deviceId = 0);
|
||||||
* @brief Process a received message (local or socket).
|
|
||||||
*
|
|
||||||
* @param message The message to process.
|
|
||||||
*
|
|
||||||
* Note: Prepares the response in the outgoing message buffer.
|
|
||||||
*/
|
|
||||||
void processMessage(SignedMessage* message);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @brief Checks that the message is valid and prepares a challenge.
|
|
||||||
*
|
|
||||||
* This function is also called when a challenge response arrives too late.
|
|
||||||
*
|
|
||||||
* @param message The message to respond to
|
|
||||||
*
|
|
||||||
* Note: Prepares the response in the outgoing message buffer.
|
|
||||||
*/
|
|
||||||
void checkAndPrepareChallenge(Message* message);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @brief Prepare a server challenge for a local or socket message.
|
|
||||||
*
|
|
||||||
* @param message The message to respond to
|
|
||||||
*
|
|
||||||
* Note: Prepares the response in the outgoing message buffer.
|
|
||||||
*/
|
|
||||||
void prepareChallenge(Message* message);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @brief Complete an unlock request for a local or socket message.
|
|
||||||
*
|
|
||||||
* @param message The message to respond to
|
|
||||||
*
|
|
||||||
* Note: Prepares the response in the outgoing message buffer.
|
|
||||||
*/
|
|
||||||
void completeUnlockRequest(Message* message);
|
|
||||||
|
|
||||||
// MARK: Responses
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @brief Prepare the outgoing message buffer for both socket and local responses.
|
|
||||||
*
|
|
||||||
* @param event The resulting state to transmit
|
|
||||||
* @param message An optional message to echo
|
|
||||||
*/
|
|
||||||
void prepareResponseBuffer(MessageResult event, Message* message = NULL);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @brief Send the prepared outgoing message to a locally connected client
|
|
||||||
*
|
|
||||||
* @param request The original request of the client
|
|
||||||
*/
|
|
||||||
void sendPreparedLocalResponse(AsyncWebServerRequest *request);
|
void sendPreparedLocalResponse(AsyncWebServerRequest *request);
|
||||||
|
void sendPreparedServerResponse();
|
||||||
|
|
||||||
/**
|
void periodicallyReconnectWifiAndSocket(uint32_t millis);
|
||||||
* @brief Send the prepared outgoing message to the server
|
|
||||||
*/
|
|
||||||
void sendPreparedResponseToServer();
|
|
||||||
|
|
||||||
// MARK: Helper
|
|
||||||
|
|
||||||
bool convertHexMessageToBinary(const char* str);
|
|
||||||
};
|
};
|
@ -3,15 +3,6 @@
|
|||||||
#include "message.h"
|
#include "message.h"
|
||||||
#include <stddef.h>
|
#include <stddef.h>
|
||||||
|
|
||||||
void enableCrypto();
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @brief Create a random server challenge.
|
|
||||||
*
|
|
||||||
* @return uint32_t
|
|
||||||
*/
|
|
||||||
uint32_t randomChallenge();
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @brief Create a message authentication code (MAC) for some data.
|
* @brief Create a message authentication code (MAC) for some data.
|
||||||
*
|
*
|
||||||
@ -19,10 +10,11 @@ uint32_t randomChallenge();
|
|||||||
* @param dataLength The number of bytes to authenticate
|
* @param dataLength The number of bytes to authenticate
|
||||||
* @param mac The output to store the MAC (must be at least 32 bytes)
|
* @param mac The output to store the MAC (must be at least 32 bytes)
|
||||||
* @param key The secret key used for authentication
|
* @param key The secret key used for authentication
|
||||||
|
* @param keyLength The length of the secret key
|
||||||
* @return true The MAC was successfully written
|
* @return true The MAC was successfully written
|
||||||
* @return false The MAC could not be created
|
* @return false The MAC could not be created
|
||||||
*/
|
*/
|
||||||
bool authenticateData(const uint8_t* data, size_t dataLength, uint8_t* mac, const uint8_t* key);
|
bool authenticateData(const uint8_t* data, size_t dataLength, uint8_t* mac, const uint8_t* key, size_t keyLength);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @brief Calculate a MAC for message content.
|
* @brief Calculate a MAC for message content.
|
||||||
@ -30,20 +22,22 @@ bool authenticateData(const uint8_t* data, size_t dataLength, uint8_t* mac, cons
|
|||||||
* @param message The message for which to calculate the MAC.
|
* @param message The message for which to calculate the MAC.
|
||||||
* @param mac The output where the computed MAC is stored
|
* @param mac The output where the computed MAC is stored
|
||||||
* @param key The secret key used for authentication
|
* @param key The secret key used for authentication
|
||||||
|
* @param keyLength The length of the secret key
|
||||||
* @return true The MAC was successfully computed
|
* @return true The MAC was successfully computed
|
||||||
* @return false The MAC could not be created
|
* @return false The MAC could not be created
|
||||||
*/
|
*/
|
||||||
bool authenticateMessage(Message* message, uint8_t* mac, const uint8_t* key);
|
bool authenticateMessage(Message* message, uint8_t* mac, const uint8_t* key, size_t keyLength);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @brief Create a message authentication code (MAC) for a message.
|
* @brief Create a message authentication code (MAC) for a message.
|
||||||
*
|
*
|
||||||
* @param message The message to authenticate
|
* @param message The message to authenticate
|
||||||
* @param key The secret key used for authentication
|
* @param key The secret key used for authentication
|
||||||
|
* @param keyLength The length of the secret key
|
||||||
* @return true The MAC was successfully added to the message
|
* @return true The MAC was successfully added to the message
|
||||||
* @return false The MAC could not be created
|
* @return false The MAC could not be created
|
||||||
*/
|
*/
|
||||||
bool authenticateMessage(SignedMessage* message, const uint8_t* key);
|
bool authenticateMessage(AuthenticatedMessage* message, const uint8_t* key, size_t keyLength);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @brief Check if a received unlock message is authentic
|
* @brief Check if a received unlock message is authentic
|
||||||
@ -54,7 +48,8 @@ bool authenticateMessage(SignedMessage* message, const uint8_t* key);
|
|||||||
*
|
*
|
||||||
* @param message The message to authenticate
|
* @param message The message to authenticate
|
||||||
* @param key The secret key used for authentication
|
* @param key The secret key used for authentication
|
||||||
|
* @param keyLength The length of the key in bytes
|
||||||
* @return true The message is authentic
|
* @return true The message is authentic
|
||||||
* @return false The message is invalid, or the MAC could not be calculated
|
* @return false The message is invalid, or the MAC could not be calculated
|
||||||
*/
|
*/
|
||||||
bool isAuthenticMessage(SignedMessage* message, const uint8_t* key);
|
bool isAuthenticMessage(AuthenticatedMessage* message, const uint8_t* key, size_t keyLength);
|
80
include/fresh.h
Normal file
80
include/fresh.h
Normal file
@ -0,0 +1,80 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <stdint.h>
|
||||||
|
|
||||||
|
struct TimeConfiguration {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @brief The timezone offset in seconds
|
||||||
|
*/
|
||||||
|
int32_t offsetToGMT;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @brief The daylight savings offset in seconds
|
||||||
|
*/
|
||||||
|
int32_t offsetDaylightSavings;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @brief The url of the NTP server
|
||||||
|
*/
|
||||||
|
const char* ntpServerUrl;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @brief The allowed discrepancy between the time of a received message
|
||||||
|
* and the device time (in seconds)
|
||||||
|
*
|
||||||
|
* A stricter (lower) value better prevents against replay attacks,
|
||||||
|
* but may lead to issues when dealing with slow networks and other
|
||||||
|
* routing delays.
|
||||||
|
*/
|
||||||
|
uint32_t allowedTimeOffset;
|
||||||
|
};
|
||||||
|
|
||||||
|
class TimeCheck {
|
||||||
|
|
||||||
|
public:
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @brief Create a time checker instance
|
||||||
|
*/
|
||||||
|
TimeCheck();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @brief Set the configuration
|
||||||
|
*/
|
||||||
|
void configure(TimeConfiguration configuration);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @brief Configure the NTP server to get the current time
|
||||||
|
*/
|
||||||
|
void startNTP();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @brief Print the current time to the serial output
|
||||||
|
*
|
||||||
|
* The time must be initialized by calling `configureNTP()` before use.
|
||||||
|
*/
|
||||||
|
void printLocalTime();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets the current epoch time
|
||||||
|
*/
|
||||||
|
uint32_t getEpochTime();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @brief Check wether the time of a message is within the allowed bounds regarding freshness.
|
||||||
|
*
|
||||||
|
* The timestamp is used to ensure 'freshness' of the messages,
|
||||||
|
* i.e. that they are not unreasonably delayed or captured and
|
||||||
|
* later replayed by an attacker.
|
||||||
|
*
|
||||||
|
* @param messageTime The timestamp of the message (seconds since epoch)
|
||||||
|
* @return true The time is within the acceptable offset of the local time
|
||||||
|
* @return false The message time is invalid
|
||||||
|
*/
|
||||||
|
bool isMessageTimeAcceptable(uint32_t messageTime);
|
||||||
|
|
||||||
|
private:
|
||||||
|
|
||||||
|
TimeConfiguration config;
|
||||||
|
};
|
@ -14,96 +14,58 @@
|
|||||||
|
|
||||||
#pragma pack(push, 1)
|
#pragma pack(push, 1)
|
||||||
|
|
||||||
typedef enum {
|
|
||||||
|
|
||||||
/// @brief The initial message from remote to device to request a challenge.
|
|
||||||
initial = 0,
|
|
||||||
|
|
||||||
/// @brief The second message in an unlock with the challenge from the device to the remote
|
|
||||||
challenge = 1,
|
|
||||||
|
|
||||||
/// @brief The third message with the signed challenge from the remote to the device
|
|
||||||
request = 2,
|
|
||||||
|
|
||||||
/// @brief The final message with the unlock result from the device to the remote
|
|
||||||
response = 3,
|
|
||||||
|
|
||||||
} MessageType;
|
|
||||||
|
|
||||||
enum class MessageResult: uint8_t {
|
|
||||||
|
|
||||||
/// @brief The message was accepted.
|
|
||||||
MessageAccepted = 0,
|
|
||||||
|
|
||||||
/// @brief The web socket received text while waiting for binary data.
|
|
||||||
TextReceived = 1,
|
|
||||||
|
|
||||||
/// @brief An unexpected socket event occured while performing the exchange.
|
|
||||||
UnexpectedSocketEvent = 2,
|
|
||||||
|
|
||||||
/// @brief The received message size is invalid.
|
|
||||||
InvalidMessageSize = 3,
|
|
||||||
|
|
||||||
/// @brief The message signature was incorrect.
|
|
||||||
MessageAuthenticationFailed = 4,
|
|
||||||
|
|
||||||
/// @brief The server challenge of the message did not match previous messages
|
|
||||||
ServerChallengeMismatch = 5,
|
|
||||||
|
|
||||||
/// @brief The client challenge of the message did not match previous messages
|
|
||||||
ClientChallengeInvalid = 6,
|
|
||||||
|
|
||||||
/// @brief An unexpected or unsupported message type was received
|
|
||||||
InvalidMessageType = 7,
|
|
||||||
|
|
||||||
/// @brief A message is already being processed
|
|
||||||
TooManyRequests = 8,
|
|
||||||
|
|
||||||
/// @brief The received message result was invalid
|
|
||||||
InvalidMessageResult = 9,
|
|
||||||
|
|
||||||
/// @brief An invalid Url parameter was set sending a message to the device over a local connection
|
|
||||||
InvalidUrlParameter = 10,
|
|
||||||
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @brief A generic message to exchange during challenge-response authentication.
|
* @brief The content of an unlock message.
|
||||||
|
*
|
||||||
|
* The content is necessary to ensure freshness of the message
|
||||||
|
* by requiring a recent time and a monotonously increasing counter.
|
||||||
|
* This prevents messages from being delayed or being blocked and
|
||||||
|
* replayed later.
|
||||||
*/
|
*/
|
||||||
typedef struct {
|
typedef struct {
|
||||||
|
|
||||||
/// @brief The type of message being sent.
|
/**
|
||||||
MessageType messageType;
|
* The timestamp of message creation
|
||||||
|
*
|
||||||
|
* The timestamp is encoded as the epoch time, i.e. seconds since 1970 (GMT).
|
||||||
|
*
|
||||||
|
* The timestamp is used to ensure 'freshness' of the messages,
|
||||||
|
* i.e. that they are not unreasonably delayed or captured and
|
||||||
|
* later replayed by an attacker.
|
||||||
|
*/
|
||||||
|
uint32_t time;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @brief The random nonce created by the remote
|
* The counter of unlock messages
|
||||||
*
|
*
|
||||||
* This nonce is a random number created by the remote, different for each unlock request.
|
* This counter must always increase with each message from the remote
|
||||||
* It is set for all message types.
|
* in order for the messages to be deemed valid. Transfering the counters
|
||||||
|
* back and forth also gives information about lost messages and potential
|
||||||
|
* attacks. Both the remote and the device keep a record of at least the
|
||||||
|
* last used counter.
|
||||||
*/
|
*/
|
||||||
uint32_t clientChallenge;
|
uint32_t id;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @brief A random number to sign by the remote
|
* @brief The id of the device sending the message
|
||||||
*
|
|
||||||
* This nonce is set by the server after receiving an initial message.
|
|
||||||
* It is set for the message types `challenge`, `request`, and `response`.
|
|
||||||
*/
|
*/
|
||||||
uint32_t serverChallenge;
|
uint8_t device;
|
||||||
|
|
||||||
/**
|
|
||||||
* @brief The response status for the previous message.
|
|
||||||
*
|
|
||||||
* It is set only for messages from the server, e.g. the `challenge` and `response` message types.
|
|
||||||
* Must be set to `MessageAccepted` for other messages.
|
|
||||||
*/
|
|
||||||
MessageResult result;
|
|
||||||
|
|
||||||
} Message;
|
} Message;
|
||||||
|
|
||||||
|
constexpr size_t messageCounterSize = sizeof(uint32_t);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @brief The signed version of a message.
|
* @brief An authenticated message by the mobile device to command unlocking.
|
||||||
*
|
*
|
||||||
|
* The message is protected by a message authentication code (MAC) based on
|
||||||
|
* a symmetric key shared by the device and the remote. This code ensures
|
||||||
|
* that the contents of the request were not altered. The message further
|
||||||
|
* contains a timestamp to ensure that the message is recent, and not replayed
|
||||||
|
* by an attacker. An additional counter is also included for this purpose,
|
||||||
|
* which must continously increase for a message to be valid. This increases
|
||||||
|
* security a bit, since the timestamp validation must be tolerant to some
|
||||||
|
* inaccuracy due to mismatching clocks.
|
||||||
*/
|
*/
|
||||||
typedef struct {
|
typedef struct {
|
||||||
|
|
||||||
@ -115,18 +77,38 @@ typedef struct {
|
|||||||
*/
|
*/
|
||||||
uint8_t mac[SHA256_MAC_SIZE];
|
uint8_t mac[SHA256_MAC_SIZE];
|
||||||
|
|
||||||
/// @brief The message
|
/**
|
||||||
|
* @brief The message content.
|
||||||
|
*
|
||||||
|
* The content is necessary to ensure freshness of the message
|
||||||
|
* by requiring a recent time and a monotonously increasing counter.
|
||||||
|
* This prevents messages from being delayed or being blocked and
|
||||||
|
* replayed later.
|
||||||
|
*/
|
||||||
Message message;
|
Message message;
|
||||||
|
|
||||||
} SignedMessage;
|
} AuthenticatedMessage;
|
||||||
|
|
||||||
constexpr size_t messageCounterSize = sizeof(uint32_t);
|
|
||||||
|
|
||||||
#pragma pack(pop)
|
#pragma pack(pop)
|
||||||
|
|
||||||
constexpr int MESSAGE_CONTENT_SIZE = sizeof(Message);
|
constexpr int MESSAGE_CONTENT_SIZE = sizeof(Message);
|
||||||
|
|
||||||
constexpr int SIGNED_MESSAGE_SIZE = sizeof(SignedMessage);
|
constexpr int AUTHENTICATED_MESSAGE_SIZE = sizeof(AuthenticatedMessage);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* An event signaled from the device
|
||||||
|
*/
|
||||||
|
enum class SesameEvent {
|
||||||
|
TextReceived = 1,
|
||||||
|
UnexpectedSocketEvent = 2,
|
||||||
|
InvalidMessageSize = 3,
|
||||||
|
MessageAuthenticationFailed = 4,
|
||||||
|
MessageTimeMismatch = 5,
|
||||||
|
MessageCounterInvalid = 6,
|
||||||
|
MessageAccepted = 7,
|
||||||
|
MessageDeviceInvalid = 8,
|
||||||
|
InvalidUrlParameter = 20,
|
||||||
|
InvalidResponseAuthentication = 21,
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @brief A callback for messages received over the socket
|
* @brief A callback for messages received over the socket
|
||||||
@ -139,5 +121,5 @@ typedef void (*MessageCallback)(uint8_t* payload, size_t length);
|
|||||||
/**
|
/**
|
||||||
* @brief A callback for socket errors
|
* @brief A callback for socket errors
|
||||||
*/
|
*/
|
||||||
typedef void (*ErrorCallback)(MessageResult event);
|
typedef void (*ErrorCallback)(SesameEvent event);
|
||||||
|
|
||||||
|
@ -2,6 +2,8 @@
|
|||||||
|
|
||||||
#include "message.h"
|
#include "message.h"
|
||||||
#include "crypto.h"
|
#include "crypto.h"
|
||||||
|
#include <WiFiMulti.h>
|
||||||
|
#include <WiFiClientSecure.h>
|
||||||
#include <WebSocketsClient.h>
|
#include <WebSocketsClient.h>
|
||||||
|
|
||||||
struct ServerConfiguration {
|
struct ServerConfiguration {
|
||||||
@ -28,17 +30,13 @@ struct ServerConfiguration {
|
|||||||
|
|
||||||
uint32_t reconnectTime;
|
uint32_t reconnectTime;
|
||||||
|
|
||||||
uint32_t socketHeartbeatIntervalMs;
|
|
||||||
uint32_t socketHeartbeatTimeoutMs;
|
|
||||||
uint8_t socketHeartbeatFailureReconnectCount;
|
|
||||||
|
|
||||||
};
|
};
|
||||||
|
|
||||||
class ServerConnectionCallbacks {
|
class ServerConnectionCallbacks {
|
||||||
|
|
||||||
public:
|
public:
|
||||||
|
|
||||||
virtual void sendServerError(MessageResult event) = 0;
|
virtual void sendServerError(SesameEvent event) = 0;
|
||||||
virtual void handleServerMessage(uint8_t* payload, size_t length) = 0;
|
virtual void handleServerMessage(uint8_t* payload, size_t length) = 0;
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -55,13 +53,11 @@ public:
|
|||||||
*/
|
*/
|
||||||
void configure(ServerConfiguration configuration, ServerConnectionCallbacks* callbacks);
|
void configure(ServerConfiguration configuration, ServerConnectionCallbacks* callbacks);
|
||||||
|
|
||||||
/**
|
void connect();
|
||||||
* @brief Call this function regularly to handle socket operations.
|
|
||||||
*
|
void disconnect();
|
||||||
* Connecting and disconnecting is done automatically.
|
|
||||||
*
|
void loop();
|
||||||
*/
|
|
||||||
void loop(uint32_t millis);
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @brief Send a response message over the socket
|
* @brief Send a response message over the socket
|
||||||
@ -71,26 +67,11 @@ public:
|
|||||||
*/
|
*/
|
||||||
void sendResponse(uint8_t* buffer, uint16_t length);
|
void sendResponse(uint8_t* buffer, uint16_t length);
|
||||||
|
|
||||||
private:
|
bool isSocketConnected() {
|
||||||
|
|
||||||
uint32_t currentTime;
|
|
||||||
|
|
||||||
bool socketIsConnected() {
|
|
||||||
return webSocket.isConnected();
|
return webSocket.isConnected();
|
||||||
}
|
}
|
||||||
|
|
||||||
void connect();
|
private:
|
||||||
|
|
||||||
void disconnect();
|
|
||||||
|
|
||||||
bool shouldReconnect = true;
|
|
||||||
bool isConnecting = false;
|
|
||||||
uint32_t connectionTimeout = 0;
|
|
||||||
uint32_t nextReconnectAttemptMs = 0;
|
|
||||||
|
|
||||||
void didDisconnect();
|
|
||||||
void didConnect();
|
|
||||||
|
|
||||||
|
|
||||||
ServerConfiguration configuration;
|
ServerConfiguration configuration;
|
||||||
|
|
||||||
|
83
include/storage.h
Normal file
83
include/storage.h
Normal file
@ -0,0 +1,83 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <stdint.h>
|
||||||
|
|
||||||
|
class Storage {
|
||||||
|
|
||||||
|
public:
|
||||||
|
|
||||||
|
Storage(uint8_t remoteDeviceCount) : remoteDeviceCount(remoteDeviceCount) { };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @brief Initialize the use of the message counter API
|
||||||
|
*
|
||||||
|
* The message counter is stored in EEPROM, which must be initialized before use.
|
||||||
|
*
|
||||||
|
* @note The ESP32 does not have a true EEPROM,
|
||||||
|
* which is emulated using a section of the flash memory.
|
||||||
|
*/
|
||||||
|
void configure();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @brief Check if a device ID is allowed
|
||||||
|
*
|
||||||
|
* @param deviceId The ID to check
|
||||||
|
* @return true The id is valid
|
||||||
|
* @return false The id is invalid
|
||||||
|
*/
|
||||||
|
bool isDeviceIdValid(uint8_t deviceId);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @brief Check if a received counter is valid
|
||||||
|
*
|
||||||
|
* The counter is valid if it is larger than the previous counter
|
||||||
|
* (larger or equal to the next expected counter).
|
||||||
|
*
|
||||||
|
* @param counter The counter to check
|
||||||
|
* @return true The counter is valid
|
||||||
|
* @return false The counter belongs to an old message
|
||||||
|
*/
|
||||||
|
bool isMessageCounterValid(uint32_t counter, uint8_t deviceId);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @brief Mark a counter of a message as used.
|
||||||
|
*
|
||||||
|
* The counter value is stored in EEPROM to persist across restarts.
|
||||||
|
*
|
||||||
|
* All messages with counters lower than the given one will become invalid.
|
||||||
|
*
|
||||||
|
* @param counter The counter used in the last message.
|
||||||
|
*/
|
||||||
|
void didUseMessageCounter(uint32_t counter, uint8_t deviceId);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @brief Get the expected count for the next message.
|
||||||
|
*
|
||||||
|
* The counter is stored in EEPROM to persist across restarts
|
||||||
|
*
|
||||||
|
* @return The next counter to use by the remote
|
||||||
|
*/
|
||||||
|
uint32_t getNextMessageCounter(uint8_t deviceId);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @brief Print info about the current message counter to the serial output
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
void printMessageCounters();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @brief Reset the message counter.
|
||||||
|
*
|
||||||
|
* @warning The counter should never be reset in production environments,
|
||||||
|
* and only together with a new secret key. Otherwise old messages may be
|
||||||
|
* used for replay attacks.
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
void resetMessageCounters();
|
||||||
|
|
||||||
|
private:
|
||||||
|
|
||||||
|
uint8_t remoteDeviceCount;
|
||||||
|
|
||||||
|
void setMessageCounter(uint32_t counter, uint8_t deviceId);
|
||||||
|
};
|
@ -13,11 +13,7 @@ platform = espressif32
|
|||||||
board = az-delivery-devkit-v4
|
board = az-delivery-devkit-v4
|
||||||
framework = arduino
|
framework = arduino
|
||||||
lib_deps =
|
lib_deps =
|
||||||
; links2004/WebSockets@^2.4.0
|
links2004/WebSockets@^2.3.7
|
||||||
madhephaestus/ESP32Servo@^1.1.0
|
madhephaestus/ESP32Servo@^1.1.0
|
||||||
ottowinter/ESPAsyncWebServer-esphome@^3.0.0
|
ottowinter/ESPAsyncWebServer-esphome@^3.0.0
|
||||||
arduino-libraries/Ethernet@^2.0.2
|
|
||||||
https://github.com/christophhagen/arduinoWebSockets#master
|
|
||||||
|
|
||||||
monitor_speed = 115200
|
monitor_speed = 115200
|
||||||
build_flags= -D WEBSOCKETS_NETWORK_TYPE=NETWORK_W5100
|
|
@ -2,48 +2,23 @@
|
|||||||
#include "crypto.h"
|
#include "crypto.h"
|
||||||
#include "config.h"
|
#include "config.h"
|
||||||
|
|
||||||
#include <SPI.h>
|
#include <WiFi.h>
|
||||||
#include <Ethernet.h>
|
|
||||||
|
|
||||||
SesameController::SesameController(uint16_t localWebServerPort) : localWebServer(localWebServerPort) {
|
SesameController::SesameController(uint16_t localWebServerPort, uint8_t remoteDeviceCount) :
|
||||||
|
storage(remoteDeviceCount), localWebServer(localWebServerPort) {
|
||||||
|
|
||||||
|
// Set up response buffer
|
||||||
|
responseStatus = (SesameEvent*) responseBuffer;
|
||||||
|
responseMessage = (AuthenticatedMessage*) (responseBuffer + 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
void SesameController::configure(ServoConfiguration servoConfig, ServerConfiguration serverConfig, EthernetConfiguration ethernetConfig, KeyConfiguration keyConfig) {
|
void SesameController::configure(ServoConfiguration servoConfig, ServerConfiguration serverConfig, TimeConfiguration timeConfig, WifiConfiguration wifiConfig, KeyConfiguration keyConfig) {
|
||||||
this->ethernetConfig = ethernetConfig;
|
this->wifiConfig = wifiConfig;
|
||||||
this->keyConfig = keyConfig;
|
this->keyConfig = keyConfig;
|
||||||
|
|
||||||
// Ensure source of random numbers without WiFi and Bluetooth
|
// Prepare EEPROM for reading and writing
|
||||||
enableCrypto();
|
storage.configure();
|
||||||
|
Serial.println("[INFO] Storage configured");
|
||||||
// Initialize SPI interface to Ethernet module
|
|
||||||
SPI.begin(ethernetConfig.spiPinSclk, ethernetConfig.spiPinMiso, ethernetConfig.spiPinMosi, ethernetConfig.spiPinSS); //SCLK, MISO, MOSI, SS
|
|
||||||
pinMode(ethernetConfig.spiPinSS, OUTPUT);
|
|
||||||
|
|
||||||
Ethernet.init(ethernetConfig.spiPinSS);
|
|
||||||
|
|
||||||
if (Ethernet.begin(ethernetConfig.macAddress, ethernetConfig.dhcpLeaseTimeoutMs, ethernetConfig.dhcpLeaseResponseTimeoutMs) == 1) {
|
|
||||||
Serial.print("[INFO] DHCP assigned IP ");
|
|
||||||
Serial.println(Ethernet.localIP());
|
|
||||||
ethernetIsConfigured = true;
|
|
||||||
} else {
|
|
||||||
// Check for Ethernet hardware present
|
|
||||||
if (Ethernet.hardwareStatus() == EthernetNoHardware) {
|
|
||||||
Serial.println("[ERROR] Ethernet shield not found.");
|
|
||||||
} else if (Ethernet.linkStatus() == LinkOFF) {
|
|
||||||
Serial.println("[ERROR] Ethernet cable is not connected.");
|
|
||||||
} else if (Ethernet.linkStatus() == Unknown) {
|
|
||||||
Serial.println("[ERROR] Ethernet cable status unknown.");
|
|
||||||
} else if (Ethernet.linkStatus() == LinkON) {
|
|
||||||
Serial.println("[INFO] Ethernet cable is connected.");
|
|
||||||
|
|
||||||
// Try to configure using IP address instead of DHCP
|
|
||||||
Ethernet.begin(ethernetConfig.macAddress, ethernetConfig.manualIp, ethernetConfig.manualDnsAddress);
|
|
||||||
Serial.print("[WARNING] DHCP failed, using self-assigned IP ");
|
|
||||||
Serial.println(Ethernet.localIP());
|
|
||||||
ethernetIsConfigured = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
servo.configure(servoConfig);
|
servo.configure(servoConfig);
|
||||||
Serial.println("[INFO] Servo configured");
|
Serial.println("[INFO] Servo configured");
|
||||||
@ -52,6 +27,9 @@ void SesameController::configure(ServoConfiguration servoConfig, ServerConfigura
|
|||||||
server.configure(serverConfig, this);
|
server.configure(serverConfig, this);
|
||||||
Serial.println("[INFO] Server connection configured");
|
Serial.println("[INFO] Server connection configured");
|
||||||
|
|
||||||
|
timeCheck.configure(timeConfig);
|
||||||
|
|
||||||
|
|
||||||
// Direct messages from the local web server to the controller
|
// Direct messages from the local web server to the controller
|
||||||
localWebServer.on("/message", HTTP_POST, [this] (AsyncWebServerRequest *request) {
|
localWebServer.on("/message", HTTP_POST, [this] (AsyncWebServerRequest *request) {
|
||||||
this->handleLocalMessage(request);
|
this->handleLocalMessage(request);
|
||||||
@ -59,12 +37,18 @@ void SesameController::configure(ServoConfiguration servoConfig, ServerConfigura
|
|||||||
});
|
});
|
||||||
|
|
||||||
Serial.println("[INFO] Local web server configured");
|
Serial.println("[INFO] Local web server configured");
|
||||||
|
|
||||||
|
//storage.resetMessageCounters();
|
||||||
|
storage.printMessageCounters();
|
||||||
}
|
}
|
||||||
|
|
||||||
void SesameController::loop(uint32_t millis) {
|
void SesameController::loop(uint32_t millis) {
|
||||||
currentTime = millis;
|
server.loop();
|
||||||
server.loop(millis);
|
|
||||||
servo.loop(millis);
|
servo.loop(millis);
|
||||||
|
|
||||||
|
periodicallyReconnectWifiAndSocket(millis);
|
||||||
|
ensureWiFiConnection(millis);
|
||||||
|
ensureWebSocketConnection();
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: Local
|
// MARK: Local
|
||||||
@ -72,154 +56,141 @@ void SesameController::loop(uint32_t millis) {
|
|||||||
void SesameController::handleLocalMessage(AsyncWebServerRequest *request) {
|
void SesameController::handleLocalMessage(AsyncWebServerRequest *request) {
|
||||||
if (!request->hasParam(messageUrlParameter)) {
|
if (!request->hasParam(messageUrlParameter)) {
|
||||||
Serial.println("Missing url parameter");
|
Serial.println("Missing url parameter");
|
||||||
prepareResponseBuffer(MessageResult::InvalidUrlParameter);
|
prepareResponseBuffer(SesameEvent::InvalidUrlParameter);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
String encoded = request->getParam(messageUrlParameter)->value();
|
String encoded = request->getParam(messageUrlParameter)->value();
|
||||||
if (!convertHexMessageToBinary(encoded.c_str())) {
|
if (!convertHexMessageToBinary(encoded.c_str())) {
|
||||||
Serial.println("Invalid hex encoding");
|
Serial.println("Invalid hex encoding");
|
||||||
prepareResponseBuffer(MessageResult::InvalidMessageSize);
|
prepareResponseBuffer(SesameEvent::InvalidMessageSize);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
processMessage(&receivedLocalMessage);
|
processMessage((AuthenticatedMessage*) receivedMessageBuffer);
|
||||||
}
|
}
|
||||||
|
|
||||||
void SesameController::sendPreparedLocalResponse(AsyncWebServerRequest *request) {
|
void SesameController::sendPreparedLocalResponse(AsyncWebServerRequest *request) {
|
||||||
request->send_P(200, "application/octet-stream", (uint8_t*) &outgoingMessage, SIGNED_MESSAGE_SIZE);
|
request->send_P(200, "application/octet-stream", responseBuffer, responseSize);
|
||||||
Serial.printf("[INFO] Local response %u\n", outgoingMessage.message.messageType);
|
Serial.printf("[INFO] Local response %u (%u bytes)\n", responseBuffer[0], responseSize);
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: Server
|
// MARK: Server
|
||||||
|
|
||||||
void SesameController::sendServerError(MessageResult result) {
|
void SesameController::sendServerError(SesameEvent event) {
|
||||||
prepareResponseBuffer(result); // No message to echo
|
prepareResponseBuffer(event);
|
||||||
sendPreparedResponseToServer();
|
sendPreparedServerResponse();
|
||||||
}
|
}
|
||||||
|
|
||||||
void SesameController::handleServerMessage(uint8_t* payload, size_t length) {
|
void SesameController::handleServerMessage(uint8_t* payload, size_t length) {
|
||||||
if (length != SIGNED_MESSAGE_SIZE) {
|
if (length != AUTHENTICATED_MESSAGE_SIZE) {
|
||||||
// No message saved to discard, don't accidentally delete for other operation
|
prepareResponseBuffer(SesameEvent::InvalidMessageSize);
|
||||||
sendServerError(MessageResult::InvalidMessageSize);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
processMessage((SignedMessage*) payload);
|
|
||||||
sendPreparedResponseToServer();
|
processMessage((AuthenticatedMessage*) payload);
|
||||||
|
sendPreparedServerResponse();
|
||||||
}
|
}
|
||||||
|
|
||||||
void SesameController::sendPreparedResponseToServer() {
|
void SesameController::sendPreparedServerResponse() {
|
||||||
server.sendResponse((uint8_t*) &outgoingMessage, SIGNED_MESSAGE_SIZE);
|
server.sendResponse(responseBuffer, responseSize);
|
||||||
Serial.printf("[INFO] Server response %u\n", outgoingMessage.message.messageType);
|
Serial.printf("[INFO] Server response %u (%u bytes)\n", responseBuffer[0], responseSize);
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: Message handling
|
// MARK: Message handling
|
||||||
|
|
||||||
void SesameController::processMessage(SignedMessage* message) {
|
void SesameController::processMessage(AuthenticatedMessage* message) {
|
||||||
// Result must be empty
|
SesameEvent event = verifyAndProcessReceivedMessage(message);
|
||||||
if (message->message.result != MessageResult::MessageAccepted) {
|
prepareResponseBuffer(event, message->message.device);
|
||||||
prepareResponseBuffer(MessageResult::InvalidMessageResult);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!isAuthenticMessage(message, keyConfig.remoteKey)) {
|
|
||||||
prepareResponseBuffer(MessageResult::MessageAuthenticationFailed);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
switch (message->message.messageType) {
|
|
||||||
case MessageType::initial:
|
|
||||||
checkAndPrepareChallenge(&message->message);
|
|
||||||
return;
|
|
||||||
case MessageType::request:
|
|
||||||
completeUnlockRequest(&message->message);
|
|
||||||
return;
|
|
||||||
default:
|
|
||||||
prepareResponseBuffer(MessageResult::InvalidMessageType);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
void SesameController::checkAndPrepareChallenge(Message* message) {
|
/**
|
||||||
// Server challenge must be empty
|
* Process a received message.
|
||||||
if (message->serverChallenge != 0) {
|
*
|
||||||
prepareResponseBuffer(MessageResult::ClientChallengeInvalid);
|
* Checks whether the received data is a valid,
|
||||||
return;
|
* and then signals that the motor should move.
|
||||||
|
*
|
||||||
|
* @param message The message received from the remote
|
||||||
|
* @return The response to signal to the server.
|
||||||
|
*/
|
||||||
|
SesameEvent SesameController::verifyAndProcessReceivedMessage(AuthenticatedMessage* message) {
|
||||||
|
if (!isAuthenticMessage(message, keyConfig.remoteKey, keySize)) {
|
||||||
|
return SesameEvent::MessageAuthenticationFailed;
|
||||||
}
|
}
|
||||||
prepareChallenge(message);
|
if (!storage.isDeviceIdValid(message->message.device)) {
|
||||||
}
|
return SesameEvent::MessageDeviceInvalid;
|
||||||
|
}
|
||||||
void SesameController::prepareChallenge(Message* message) {
|
if (!storage.isMessageCounterValid(message->message.id, message->message.device)) {
|
||||||
if (hasCurrentChallenge()) {
|
return SesameEvent::MessageCounterInvalid;
|
||||||
Serial.println("[INFO] Overwriting old challenge");
|
}
|
||||||
|
if (!timeCheck.isMessageTimeAcceptable(message->message.time)) {
|
||||||
|
return SesameEvent::MessageTimeMismatch;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Set challenge and respond
|
storage.didUseMessageCounter(message->message.id, message->message.device);
|
||||||
currentClientChallenge = message->clientChallenge;
|
|
||||||
currentServerChallenge = randomChallenge();
|
|
||||||
currentChallengeExpiry = currentTime + keyConfig.challengeExpiryMs;
|
|
||||||
|
|
||||||
prepareResponseBuffer(MessageResult::MessageAccepted, message);
|
|
||||||
}
|
|
||||||
|
|
||||||
void SesameController::completeUnlockRequest(Message* message) {
|
|
||||||
// Client and server challenge must match
|
|
||||||
if (message->clientChallenge != currentClientChallenge) {
|
|
||||||
prepareResponseBuffer(MessageResult::ClientChallengeInvalid, message);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (message->serverChallenge != currentServerChallenge) {
|
|
||||||
prepareResponseBuffer(MessageResult::ServerChallengeMismatch, message);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!hasCurrentChallenge()) {
|
|
||||||
// Directly send new challenge on expiry, since rest of message is valid
|
|
||||||
// This allows the remote to directly try again without requesting a new challenge.
|
|
||||||
// Security note: The client nonce is reused in this case, but an attacker would still
|
|
||||||
// not be able to create a valid unlock request due to the new server nonce.
|
|
||||||
prepareChallenge(message);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
clearCurrentChallenge();
|
|
||||||
|
|
||||||
// Move servo
|
// Move servo
|
||||||
servo.pressButton();
|
servo.pressButton();
|
||||||
prepareResponseBuffer(MessageResult::MessageAccepted, message);
|
Serial.printf("[Info] Accepted message %d\n", message->message.id);
|
||||||
Serial.println("[INFO] Accepted message");
|
return SesameEvent::MessageAccepted;
|
||||||
}
|
}
|
||||||
|
|
||||||
void SesameController::prepareResponseBuffer(MessageResult result, Message* message) {
|
bool allowMessageResponse(SesameEvent event) {
|
||||||
outgoingMessage.message.result = result;
|
switch (event) {
|
||||||
if (message != NULL) {
|
case SesameEvent::MessageTimeMismatch:
|
||||||
outgoingMessage.message.clientChallenge = message->clientChallenge;
|
case SesameEvent::MessageCounterInvalid:
|
||||||
outgoingMessage.message.serverChallenge = message->serverChallenge;
|
case SesameEvent::MessageAccepted:
|
||||||
// All outgoing messages are responses, except if an initial message is accepted
|
case SesameEvent::MessageDeviceInvalid:
|
||||||
if (message->messageType == MessageType::initial && result == MessageResult::MessageAccepted) {
|
return true;
|
||||||
outgoingMessage.message.messageType = MessageType::challenge;
|
default:
|
||||||
} else {
|
return false;
|
||||||
outgoingMessage.message.messageType = MessageType::response;
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
outgoingMessage.message.clientChallenge = message->clientChallenge;
|
|
||||||
outgoingMessage.message.serverChallenge = message->serverChallenge;
|
|
||||||
outgoingMessage.message.messageType = MessageType::response;
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (!authenticateMessage(&outgoingMessage, keyConfig.localKey)) {
|
void SesameController::prepareResponseBuffer(SesameEvent event, uint8_t deviceId) {
|
||||||
Serial.println("[ERROR] Failed to sign message");
|
*responseStatus = event;
|
||||||
|
responseSize = 1;
|
||||||
|
if (!allowMessageResponse(event)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
responseMessage->message.time = timeCheck.getEpochTime();
|
||||||
|
responseMessage->message.id = storage.getNextMessageCounter(deviceId);
|
||||||
|
responseMessage->message.device = deviceId;
|
||||||
|
|
||||||
|
if (!authenticateMessage(responseMessage, keyConfig.localKey, keySize)) {
|
||||||
|
*responseStatus = SesameEvent::InvalidResponseAuthentication;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
responseSize += AUTHENTICATED_MESSAGE_SIZE;
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: Reconnecting
|
||||||
|
|
||||||
|
void SesameController::ensureWiFiConnection(uint32_t millis) {
|
||||||
|
static uint32_t nextWifiReconnect = 0;
|
||||||
|
// Reconnect to WiFi
|
||||||
|
if(millis > nextWifiReconnect && WiFi.status() != WL_CONNECTED) {
|
||||||
|
Serial.println("[INFO] Reconnecting WiFi...");
|
||||||
|
WiFi.setHostname(wifiConfig.networkName);
|
||||||
|
WiFi.begin(wifiConfig.ssid, wifiConfig.password);
|
||||||
|
isReconnecting = true;
|
||||||
|
nextWifiReconnect = millis + wifiConfig.reconnectInterval;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void SesameController::ensureWebSocketConnection() {
|
||||||
|
if (isReconnecting && WiFi.status() == WL_CONNECTED) {
|
||||||
|
isReconnecting = false;
|
||||||
|
Serial.print("WiFi IP address: ");
|
||||||
|
Serial.println(WiFi.localIP());
|
||||||
|
server.connect();
|
||||||
|
timeCheck.startNTP();
|
||||||
|
timeCheck.printLocalTime();
|
||||||
|
localWebServer.begin();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: Helper
|
// MARK: Helper
|
||||||
|
|
||||||
/**
|
// Based on https://stackoverflow.com/a/23898449/266720
|
||||||
* @brief
|
|
||||||
*
|
|
||||||
* Based on https://stackoverflow.com/a/23898449/266720
|
|
||||||
*
|
|
||||||
* @param str
|
|
||||||
* @return true
|
|
||||||
* @return false
|
|
||||||
*/
|
|
||||||
bool SesameController::convertHexMessageToBinary(const char* str) {
|
bool SesameController::convertHexMessageToBinary(const char* str) {
|
||||||
uint8_t* buffer = (uint8_t*) &receivedLocalMessage;
|
|
||||||
// TODO: Fail if invalid hex values are used
|
// TODO: Fail if invalid hex values are used
|
||||||
uint8_t idx0, idx1;
|
uint8_t idx0, idx1;
|
||||||
|
|
||||||
@ -232,7 +203,7 @@ bool SesameController::convertHexMessageToBinary(const char* str) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
size_t len = strlen(str);
|
size_t len = strlen(str);
|
||||||
if (len != SIGNED_MESSAGE_SIZE * 2) {
|
if (len != AUTHENTICATED_MESSAGE_SIZE * 2) {
|
||||||
// Require exact message size
|
// Require exact message size
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@ -240,7 +211,17 @@ bool SesameController::convertHexMessageToBinary(const char* str) {
|
|||||||
for (size_t pos = 0; pos < len; pos += 2) {
|
for (size_t pos = 0; pos < len; pos += 2) {
|
||||||
idx0 = ((uint8_t)str[pos+0] & 0x1F) ^ 0x10;
|
idx0 = ((uint8_t)str[pos+0] & 0x1F) ^ 0x10;
|
||||||
idx1 = ((uint8_t)str[pos+1] & 0x1F) ^ 0x10;
|
idx1 = ((uint8_t)str[pos+1] & 0x1F) ^ 0x10;
|
||||||
buffer[pos/2] = (uint8_t)(hashmap[idx0] << 4) | hashmap[idx1];
|
receivedMessageBuffer[pos/2] = (uint8_t)(hashmap[idx0] << 4) | hashmap[idx1];
|
||||||
};
|
};
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void SesameController::periodicallyReconnectWifiAndSocket(uint32_t millis) {
|
||||||
|
static uint32_t nextWifiReconnect = wifiConfig.periodicReconnectInterval;
|
||||||
|
if (millis > nextWifiReconnect) {
|
||||||
|
nextWifiReconnect += wifiConfig.periodicReconnectInterval;
|
||||||
|
|
||||||
|
server.disconnect();
|
||||||
|
WiFi.disconnect();
|
||||||
|
}
|
||||||
|
}
|
@ -1,19 +1,8 @@
|
|||||||
#include "crypto.h"
|
#include "crypto.h"
|
||||||
#include "config.h"
|
|
||||||
#include <string.h>
|
#include <string.h>
|
||||||
#include <mbedtls/md.h>
|
#include <mbedtls/md.h>
|
||||||
#include <esp_random.h>
|
|
||||||
#include <bootloader_random.h>
|
|
||||||
|
|
||||||
void enableCrypto() {
|
bool authenticateData(const uint8_t* data, size_t dataLength, uint8_t* mac, const uint8_t* key, size_t keyLength) {
|
||||||
bootloader_random_enable();
|
|
||||||
}
|
|
||||||
|
|
||||||
uint32_t randomChallenge() {
|
|
||||||
return esp_random();
|
|
||||||
}
|
|
||||||
|
|
||||||
bool authenticateData(const uint8_t* data, size_t dataLength, uint8_t* mac, const uint8_t* key) {
|
|
||||||
mbedtls_md_context_t ctx;
|
mbedtls_md_context_t ctx;
|
||||||
mbedtls_md_type_t md_type = MBEDTLS_MD_SHA256;
|
mbedtls_md_type_t md_type = MBEDTLS_MD_SHA256;
|
||||||
int result;
|
int result;
|
||||||
@ -23,7 +12,7 @@ bool authenticateData(const uint8_t* data, size_t dataLength, uint8_t* mac, cons
|
|||||||
if (result) {
|
if (result) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
result = mbedtls_md_hmac_starts(&ctx, key, keySize);
|
result = mbedtls_md_hmac_starts(&ctx, key, keyLength);
|
||||||
if (result) {
|
if (result) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@ -39,17 +28,17 @@ bool authenticateData(const uint8_t* data, size_t dataLength, uint8_t* mac, cons
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
bool authenticateMessage(Message* message, uint8_t* mac, const uint8_t* key) {
|
bool authenticateMessage(Message* message, uint8_t* mac, const uint8_t* key, size_t keyLength) {
|
||||||
return authenticateData((const uint8_t*) message, MESSAGE_CONTENT_SIZE, mac, key);
|
return authenticateData((const uint8_t*) message, MESSAGE_CONTENT_SIZE, mac, key, keyLength);
|
||||||
}
|
}
|
||||||
|
|
||||||
bool authenticateMessage(SignedMessage* message, const uint8_t* key) {
|
bool authenticateMessage(AuthenticatedMessage* message, const uint8_t* key, size_t keyLength) {
|
||||||
return authenticateMessage(&message->message, message->mac, key);
|
return authenticateMessage(&message->message, message->mac, key, keyLength);
|
||||||
}
|
}
|
||||||
|
|
||||||
bool isAuthenticMessage(SignedMessage* message, const uint8_t* key) {
|
bool isAuthenticMessage(AuthenticatedMessage* message, const uint8_t* key, size_t keyLength) {
|
||||||
uint8_t mac[SHA256_MAC_SIZE];
|
uint8_t mac[SHA256_MAC_SIZE];
|
||||||
if (!authenticateMessage(&message->message, mac, key)) {
|
if (!authenticateMessage(&message->message, mac, key, keyLength)) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
return memcmp(mac, message->mac, SHA256_MAC_SIZE) == 0;
|
return memcmp(mac, message->mac, SHA256_MAC_SIZE) == 0;
|
||||||
|
49
src/fresh.cpp
Normal file
49
src/fresh.cpp
Normal file
@ -0,0 +1,49 @@
|
|||||||
|
#include "fresh.h"
|
||||||
|
|
||||||
|
#include <Arduino.h> // configTime()
|
||||||
|
#include <time.h>
|
||||||
|
|
||||||
|
TimeCheck::TimeCheck() { }
|
||||||
|
|
||||||
|
void TimeCheck::configure(TimeConfiguration configuration) {
|
||||||
|
config = configuration;
|
||||||
|
}
|
||||||
|
|
||||||
|
void TimeCheck::startNTP() {
|
||||||
|
configTime(config.offsetToGMT, config.offsetDaylightSavings, config.ntpServerUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
void TimeCheck::printLocalTime() {
|
||||||
|
struct tm timeinfo;
|
||||||
|
if (getLocalTime(&timeinfo)) {
|
||||||
|
Serial.println(&timeinfo, "[INFO] Time is %A, %d. %B %Y %H:%M:%S");
|
||||||
|
} else {
|
||||||
|
Serial.println("[WARN] No local time available");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
uint32_t TimeCheck::getEpochTime() {
|
||||||
|
time_t now;
|
||||||
|
struct tm timeinfo;
|
||||||
|
if (!getLocalTime(&timeinfo)) {
|
||||||
|
Serial.println("[WARN] Failed to obtain local time");
|
||||||
|
return(0);
|
||||||
|
}
|
||||||
|
time(&now);
|
||||||
|
return now;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool TimeCheck::isMessageTimeAcceptable(uint32_t t) {
|
||||||
|
uint32_t localTime = getEpochTime();
|
||||||
|
if (localTime == 0) {
|
||||||
|
Serial.println("No epoch time available");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (t > localTime + config.allowedTimeOffset) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (t < localTime - config.allowedTimeOffset) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
40
src/main.cpp
40
src/main.cpp
@ -4,15 +4,6 @@
|
|||||||
*
|
*
|
||||||
* The code for a simple door unlock mechanism where a servo pushes on an existing
|
* The code for a simple door unlock mechanism where a servo pushes on an existing
|
||||||
* physical button.
|
* physical button.
|
||||||
*
|
|
||||||
* On compile error:
|
|
||||||
*
|
|
||||||
* In <Server.h>
|
|
||||||
*
|
|
||||||
* change:
|
|
||||||
* virtual void begin(uint16_t port=0) =0;
|
|
||||||
* to:
|
|
||||||
* virtual void begin() =0;
|
|
||||||
*/
|
*/
|
||||||
#include <Arduino.h>
|
#include <Arduino.h>
|
||||||
|
|
||||||
@ -22,7 +13,7 @@
|
|||||||
#include "controller.h"
|
#include "controller.h"
|
||||||
#include "config.h"
|
#include "config.h"
|
||||||
|
|
||||||
SesameController controller(localPort);
|
SesameController controller(localPort, remoteDeviceCount);
|
||||||
|
|
||||||
void setup() {
|
void setup() {
|
||||||
Serial.begin(serialBaudRate);
|
Serial.begin(serialBaudRate);
|
||||||
@ -44,30 +35,29 @@ void setup() {
|
|||||||
.path = serverPath,
|
.path = serverPath,
|
||||||
.key = serverAccessKey,
|
.key = serverAccessKey,
|
||||||
.reconnectTime = 5000,
|
.reconnectTime = 5000,
|
||||||
.socketHeartbeatIntervalMs = socketHeartbeatIntervalMs,
|
|
||||||
.socketHeartbeatTimeoutMs = socketHeartbeatTimeoutMs,
|
|
||||||
.socketHeartbeatFailureReconnectCount = socketHeartbeatFailureReconnectCount,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
EthernetConfiguration ethernetConfig {
|
TimeConfiguration timeConfig {
|
||||||
.macAddress = ethernetMacAddress,
|
.offsetToGMT = timeOffsetToGMT,
|
||||||
.spiPinMiso = spiPinMiso,
|
.offsetDaylightSavings = timeOffsetDaylightSavings,
|
||||||
.spiPinMosi = spiPinMosi,
|
.ntpServerUrl = ntpServerUrl,
|
||||||
.spiPinSclk = spiPinSclk,
|
.allowedTimeOffset = 60,
|
||||||
.spiPinSS = spiPinSS,
|
};
|
||||||
.dhcpLeaseTimeoutMs = dhcpLeaseTimeoutMs,
|
|
||||||
.dhcpLeaseResponseTimeoutMs = dhcpLeaseResponseTimeoutMs,
|
WifiConfiguration wifiConfig {
|
||||||
.manualIp = manualIpAddress,
|
.ssid = wifiSSID,
|
||||||
.manualDnsAddress = manualDnsServerAddress,
|
.password = wifiPassword,
|
||||||
|
.networkName = networkName,
|
||||||
|
.reconnectInterval = wifiReconnectInterval,
|
||||||
|
.periodicReconnectInterval = wifiPeriodicReconnectInterval,
|
||||||
};
|
};
|
||||||
|
|
||||||
KeyConfiguration keyConfig {
|
KeyConfiguration keyConfig {
|
||||||
.remoteKey = remoteKey,
|
.remoteKey = remoteKey,
|
||||||
.localKey = localKey,
|
.localKey = localKey,
|
||||||
.challengeExpiryMs = challengeExpiryMs,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
controller.configure(servoConfig, serverConfig, ethernetConfig, keyConfig);
|
controller.configure(servoConfig, serverConfig, timeConfig, wifiConfig, keyConfig);
|
||||||
}
|
}
|
||||||
|
|
||||||
void loop() {
|
void loop() {
|
||||||
|
@ -20,11 +20,7 @@ void ServerConnection::connect() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
isConnecting = true;
|
webSocket.beginSSL(configuration.url, configuration.port, configuration.path);
|
||||||
Serial.printf("[INFO] Connecting to %s:%d%s\n", configuration.url, configuration.port, configuration.path);
|
|
||||||
connectionTimeout = currentTime + configuration.socketHeartbeatIntervalMs;
|
|
||||||
webSocket.begin(configuration.url, configuration.port, configuration.path);
|
|
||||||
webSocket.setAuthorization(configuration.key);
|
|
||||||
|
|
||||||
std::function<void(WStype_t, uint8_t *, size_t)> f = [this](WStype_t type, uint8_t *payload, size_t length) {
|
std::function<void(WStype_t, uint8_t *, size_t)> f = [this](WStype_t type, uint8_t *payload, size_t length) {
|
||||||
this->webSocketEventHandler(type, payload, length);
|
this->webSocketEventHandler(type, payload, length);
|
||||||
@ -33,50 +29,26 @@ void ServerConnection::connect() {
|
|||||||
webSocket.setReconnectInterval(configuration.reconnectTime);
|
webSocket.setReconnectInterval(configuration.reconnectTime);
|
||||||
}
|
}
|
||||||
|
|
||||||
void ServerConnection::didDisconnect() {
|
|
||||||
if (shouldReconnect || isConnecting) {
|
|
||||||
return; // Disconnect already registered.
|
|
||||||
}
|
|
||||||
Serial.println("[INFO] Socket disconnected");
|
|
||||||
nextReconnectAttemptMs = currentTime + configuration.socketHeartbeatIntervalMs;
|
|
||||||
shouldReconnect = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
void ServerConnection::didConnect() {
|
|
||||||
isConnecting = false;
|
|
||||||
Serial.println("[INFO] Socket connected");
|
|
||||||
webSocket.enableHeartbeat(configuration.socketHeartbeatIntervalMs, configuration.socketHeartbeatTimeoutMs, configuration.socketHeartbeatFailureReconnectCount);
|
|
||||||
}
|
|
||||||
|
|
||||||
void ServerConnection::disconnect() {
|
void ServerConnection::disconnect() {
|
||||||
webSocket.disconnect();
|
webSocket.disconnect();
|
||||||
}
|
}
|
||||||
|
|
||||||
void ServerConnection::loop(uint32_t millis) {
|
void ServerConnection::loop() {
|
||||||
currentTime = millis;
|
|
||||||
webSocket.loop();
|
webSocket.loop();
|
||||||
if (shouldReconnect && !isConnecting) {
|
|
||||||
shouldReconnect = false;
|
|
||||||
connect();
|
|
||||||
}
|
|
||||||
if (isConnecting && millis > connectionTimeout) {
|
|
||||||
Serial.println("[INFO] Failed to connect");
|
|
||||||
disconnect();
|
|
||||||
shouldReconnect = true;
|
|
||||||
isConnecting = false;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
void ServerConnection::webSocketEventHandler(WStype_t type, uint8_t * payload, size_t length) {
|
void ServerConnection::webSocketEventHandler(WStype_t type, uint8_t * payload, size_t length) {
|
||||||
switch(type) {
|
switch(type) {
|
||||||
case WStype_DISCONNECTED:
|
case WStype_DISCONNECTED:
|
||||||
didDisconnect();
|
Serial.println("[INFO] Socket disconnected.");
|
||||||
break;
|
break;
|
||||||
case WStype_CONNECTED:
|
case WStype_CONNECTED:
|
||||||
didConnect();
|
webSocket.sendTXT(configuration.key);
|
||||||
|
Serial.printf("[INFO] Socket connected to url: %s\n", payload);
|
||||||
|
webSocket.enableHeartbeat(pingInterval, pongTimeout, disconnectTimeoutCount);
|
||||||
break;
|
break;
|
||||||
case WStype_TEXT:
|
case WStype_TEXT:
|
||||||
controller->sendServerError(MessageResult::TextReceived);
|
controller->sendServerError(SesameEvent::TextReceived);
|
||||||
break;
|
break;
|
||||||
case WStype_BIN:
|
case WStype_BIN:
|
||||||
controller->handleServerMessage(payload, length);
|
controller->handleServerMessage(payload, length);
|
||||||
@ -84,20 +56,16 @@ switch(type) {
|
|||||||
case WStype_PONG:
|
case WStype_PONG:
|
||||||
break;
|
break;
|
||||||
case WStype_PING:
|
case WStype_PING:
|
||||||
break;
|
|
||||||
case WStype_ERROR:
|
case WStype_ERROR:
|
||||||
case WStype_FRAGMENT_TEXT_START:
|
case WStype_FRAGMENT_TEXT_START:
|
||||||
case WStype_FRAGMENT_BIN_START:
|
case WStype_FRAGMENT_BIN_START:
|
||||||
case WStype_FRAGMENT:
|
case WStype_FRAGMENT:
|
||||||
case WStype_FRAGMENT_FIN:
|
case WStype_FRAGMENT_FIN:
|
||||||
Serial.printf("[WARN] Unexpected socket event %d\n", type);
|
controller->sendServerError(SesameEvent::UnexpectedSocketEvent);
|
||||||
controller->sendServerError(MessageResult::UnexpectedSocketEvent);
|
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void ServerConnection::sendResponse(uint8_t* buffer, uint16_t length) {
|
void ServerConnection::sendResponse(uint8_t* buffer, uint16_t length) {
|
||||||
if (socketIsConnected()) {
|
webSocket.sendBIN(buffer, length);
|
||||||
webSocket.sendBIN(buffer, length);
|
|
||||||
}
|
|
||||||
}
|
}
|
53
src/storage.cpp
Normal file
53
src/storage.cpp
Normal file
@ -0,0 +1,53 @@
|
|||||||
|
#include "storage.h"
|
||||||
|
#include "message.h"
|
||||||
|
#include <EEPROM.h>
|
||||||
|
|
||||||
|
void Storage::configure() {
|
||||||
|
EEPROM.begin(messageCounterSize * remoteDeviceCount);
|
||||||
|
}
|
||||||
|
|
||||||
|
bool Storage::isDeviceIdValid(uint8_t deviceId) {
|
||||||
|
return deviceId < remoteDeviceCount;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool Storage::isMessageCounterValid(uint32_t counter, uint8_t deviceId) {
|
||||||
|
return counter >= getNextMessageCounter(deviceId);
|
||||||
|
}
|
||||||
|
|
||||||
|
void Storage::didUseMessageCounter(uint32_t counter, uint8_t deviceId) {
|
||||||
|
// Store the next counter, so that resetting starts at 0
|
||||||
|
setMessageCounter(counter+1, deviceId);
|
||||||
|
}
|
||||||
|
|
||||||
|
void Storage::setMessageCounter(uint32_t counter, uint8_t deviceId) {
|
||||||
|
int offset = deviceId * messageCounterSize;
|
||||||
|
EEPROM.write(offset + 0, (counter >> 24) & 0xFF);
|
||||||
|
EEPROM.write(offset + 1, (counter >> 16) & 0xFF);
|
||||||
|
EEPROM.write(offset + 2, (counter >> 8) & 0xFF);
|
||||||
|
EEPROM.write(offset + 3, counter & 0xFF);
|
||||||
|
EEPROM.commit();
|
||||||
|
}
|
||||||
|
|
||||||
|
uint32_t Storage::getNextMessageCounter(uint8_t deviceId) {
|
||||||
|
int offset = deviceId * messageCounterSize;
|
||||||
|
uint32_t counter = (uint32_t) EEPROM.read(offset + 0) << 24;
|
||||||
|
counter += (uint32_t) EEPROM.read(offset + 1) << 16;
|
||||||
|
counter += (uint32_t) EEPROM.read(offset + 2) << 8;
|
||||||
|
counter += (uint32_t) EEPROM.read(offset + 3);
|
||||||
|
return counter;
|
||||||
|
}
|
||||||
|
|
||||||
|
void Storage::printMessageCounters() {
|
||||||
|
Serial.print("[INFO] Next message numbers:");
|
||||||
|
for (uint8_t i = 0; i < remoteDeviceCount; i += 1) {
|
||||||
|
Serial.printf(" %u", getNextMessageCounter(i));
|
||||||
|
}
|
||||||
|
Serial.println("");
|
||||||
|
}
|
||||||
|
|
||||||
|
void Storage::resetMessageCounters() {
|
||||||
|
for (uint8_t i = 0; i < remoteDeviceCount; i += 1) {
|
||||||
|
setMessageCounter(0, i);
|
||||||
|
}
|
||||||
|
Serial.println("[WARN] Message counters reset");
|
||||||
|
}
|
Loading…
Reference in New Issue
Block a user