From 915b71c2ce7a20ffee37b4868841b7763f48836a Mon Sep 17 00:00:00 2001 From: agessaman Date: Tue, 21 Jul 2026 09:33:41 -0700 Subject: [PATCH 1/6] feat(jwt): add Ed25519 token verification and base64url decode Adds JWTHelper::verifyToken (signature check + claim extraction, hex or base64url signatures) and JWTHelper::base64UrlDecode, complementing the existing token-creation path. Firmware-only; used by remote command auth. --- src/helpers/JWTHelper.cpp | 163 +++++++++++++++++++++++++++++++++++++- src/helpers/JWTHelper.h | 52 ++++++++++-- 2 files changed, 206 insertions(+), 9 deletions(-) diff --git a/src/helpers/JWTHelper.cpp b/src/helpers/JWTHelper.cpp index 0b73d87ec7..8d825e639a 100644 --- a/src/helpers/JWTHelper.cpp +++ b/src/helpers/JWTHelper.cpp @@ -2,6 +2,7 @@ #include #include #include +#include #include "ed_25519.h" #include "mbedtls/base64.h" @@ -192,7 +193,167 @@ size_t JWTHelper::createPayload( if (len == 0 || len >= sizeof(jsonBuffer)) { return 0; } - + return base64UrlEncode((uint8_t*)jsonBuffer, len, output, outputSize); } +size_t JWTHelper::base64UrlDecode(const char* input, uint8_t* output, size_t outputSize) { + if (!input || !output || outputSize == 0) { + return 0; + } + + size_t inputLen = strlen(input); + if (inputLen == 0) { + return 0; + } + + // base64url -> base64 (with padding) in a heap buffer; keeps the MQTT task stack small. + char* b64 = (char*)malloc(inputLen + 4 + 1); + if (!b64) { + return 0; + } + for (size_t i = 0; i < inputLen; i++) { + char c = input[i]; + b64[i] = (c == '-') ? '+' : (c == '_') ? '/' : c; + } + size_t padding = (4 - (inputLen % 4)) % 4; + for (size_t i = 0; i < padding; i++) { + b64[inputLen + i] = '='; + } + b64[inputLen + padding] = '\0'; + + size_t outlen = 0; + int ret = mbedtls_base64_decode(output, outputSize, &outlen, + (const unsigned char*)b64, inputLen + padding); + free(b64); + return (ret != 0) ? 0 : outlen; +} + +bool JWTHelper::verifyToken( + const char* token, + const uint8_t* expected_public_key, + size_t key_len, + char* extracted_public_key, + size_t extracted_key_size, + char* extracted_nonce, + size_t nonce_size, + unsigned long* issued_at, + unsigned long* expires_at +) { + if (!token || !extracted_public_key || extracted_key_size < 65) { + return false; + } + + // Split header.payload.signature + const char* dot1 = strchr(token, '.'); + if (!dot1) return false; + const char* dot2 = strchr(dot1 + 1, '.'); + if (!dot2) return false; + + size_t headerLen = dot1 - token; + size_t payloadLen = dot2 - (dot1 + 1); + size_t signatureLen = strlen(dot2 + 1); + + // Decode and parse the payload JSON (heap-allocated to spare the task stack). + char* payload_b64 = (char*)malloc(payloadLen + 1); + if (!payload_b64) return false; + memcpy(payload_b64, dot1 + 1, payloadLen); + payload_b64[payloadLen] = '\0'; + + char* payload = (char*)malloc(512); + if (!payload) { free(payload_b64); return false; } + size_t payloadDecodedLen = base64UrlDecode(payload_b64, (uint8_t*)payload, 512); + free(payload_b64); + if (payloadDecodedLen == 0) { free(payload); return false; } + payload[payloadDecodedLen] = '\0'; + + DynamicJsonDocument* doc = new DynamicJsonDocument(512); + if (!doc) { free(payload); return false; } + DeserializationError error = deserializeJson(*doc, payload); + free(payload); + if (error) { delete doc; return false; } + + // publicKey claim (64 hex chars) is mandatory + const char* pubkey_str = (*doc)["publicKey"]; + if (!pubkey_str || strlen(pubkey_str) != 64) { delete doc; return false; } + strncpy(extracted_public_key, pubkey_str, extracted_key_size - 1); + extracted_public_key[extracted_key_size - 1] = '\0'; + + if (extracted_nonce && nonce_size > 0) { + const char* nonce_str = (*doc)["nonce"]; + if (nonce_str) { + strncpy(extracted_nonce, nonce_str, nonce_size - 1); + extracted_nonce[nonce_size - 1] = '\0'; + } else { + extracted_nonce[0] = '\0'; + } + } + + unsigned long iat = doc->containsKey("iat") ? (*doc)["iat"].as() : 0; + unsigned long exp = doc->containsKey("exp") ? (*doc)["exp"].as() : 0; + if (issued_at) *issued_at = iat; + if (expires_at) *expires_at = exp; + delete doc; + + // Reject expired tokens when the clock is set and an exp claim is present. + if (exp > 0) { + unsigned long current_time = time(nullptr); + if (current_time > 0 && current_time >= exp) { + return false; + } + } + + uint8_t pubkey_bytes[PUB_KEY_SIZE]; + if (!mesh::Utils::fromHex(pubkey_bytes, PUB_KEY_SIZE, extracted_public_key)) { + return false; + } + if (expected_public_key && key_len == PUB_KEY_SIZE) { + if (memcmp(pubkey_bytes, expected_public_key, PUB_KEY_SIZE) != 0) { + return false; + } + } + + // Decode the signature: hex (128 chars) or base64url. + uint8_t signature[64]; + bool is_hex = (signatureLen == 128); + if (is_hex) { + for (size_t i = 0; i < signatureLen; i++) { + char c = dot2[1 + i]; + if (!((c >= '0' && c <= '9') || (c >= 'A' && c <= 'F') || (c >= 'a' && c <= 'f'))) { + is_hex = false; + break; + } + } + } + if (is_hex) { + if (!mesh::Utils::fromHex(signature, 64, dot2 + 1)) return false; + } else { + char* sig_b64 = (char*)malloc(signatureLen + 1); + if (!sig_b64) return false; + memcpy(sig_b64, dot2 + 1, signatureLen); + sig_b64[signatureLen] = '\0'; + size_t sigDecodedLen = base64UrlDecode(sig_b64, signature, 64); + free(sig_b64); + if (sigDecodedLen != 64) return false; + } + + // Signing input is the encoded header.payload (everything before the last dot). + size_t signingInputLen = headerLen + 1 + payloadLen; + if (signingInputLen >= 1024) return false; + char* signingInput = (char*)malloc(signingInputLen + 1); + if (!signingInput) return false; + memcpy(signingInput, token, signingInputLen); + signingInput[signingInputLen] = '\0'; + +#ifdef ESP_PLATFORM + yield(); // feed the watchdog around the verify +#endif + int verify_result = ed25519_verify(signature, (const unsigned char*)signingInput, signingInputLen, pubkey_bytes); +#ifdef ESP_PLATFORM + yield(); +#endif + + free(signingInput); + return (verify_result == 1); +} + diff --git a/src/helpers/JWTHelper.h b/src/helpers/JWTHelper.h index a84889d891..04ce4e7470 100644 --- a/src/helpers/JWTHelper.h +++ b/src/helpers/JWTHelper.h @@ -4,16 +4,16 @@ #include "Identity.h" /** - * JWT Helper for creating authentication tokens - * - * This class provides functionality to create JWT-style authentication tokens - * signed with Ed25519 private keys for MQTT authentication. + * JWT Helper for creating and verifying authentication tokens + * + * This class provides functionality to create and verify JWT-style + * authentication tokens signed with Ed25519 private keys. */ class JWTHelper { public: /** * Create an authentication token for MQTT authentication - * + * * @param identity LocalIdentity instance for signing * @param audience Audience string (e.g., "mqtt-us-v1.letsmesh.net") * @param issuedAt Unix timestamp (0 for current time) @@ -37,10 +37,35 @@ class JWTHelper { const char* email = nullptr ); -private: + /** + * Verify a JWT token's Ed25519 signature and extract its claims. + * + * @param token JWT token string (header.payload.signature) + * @param expected_public_key Expected signing key, or nullptr to accept any (caller authorizes) + * @param key_len Length of expected_public_key (PUB_KEY_SIZE) when provided + * @param extracted_public_key Output buffer for the signing key (hex) from the payload + * @param extracted_key_size Size of extracted_public_key (must be >= 65) + * @param extracted_nonce Output buffer for the nonce claim (may be nullptr) + * @param nonce_size Size of extracted_nonce + * @param issued_at Output for the iat claim (may be nullptr) + * @param expires_at Output for the exp claim (may be nullptr) + * @return true if the signature verifies and the token is not expired + */ + static bool verifyToken( + const char* token, + const uint8_t* expected_public_key, + size_t key_len, + char* extracted_public_key, + size_t extracted_key_size, + char* extracted_nonce, + size_t nonce_size, + unsigned long* issued_at, + unsigned long* expires_at + ); + /** * Base64 URL encode data - * + * * @param input Input data * @param inputLen Length of input data * @param output Output buffer @@ -48,7 +73,18 @@ class JWTHelper { * @return Length of encoded data, or 0 on error */ static size_t base64UrlEncode(const uint8_t* input, size_t inputLen, char* output, size_t outputSize); - + + /** + * Base64 URL decode data + * + * @param input Input base64url string + * @param output Output buffer + * @param outputSize Size of output buffer + * @return Length of decoded data, or 0 on error + */ + static size_t base64UrlDecode(const char* input, uint8_t* output, size_t outputSize); + +private: /** * Create JWT header * From 02831a0f5470cff7cb90916868593b1c1b5ea9a9 Mon Sep 17 00:00:00 2001 From: agessaman Date: Tue, 21 Jul 2026 09:33:41 -0700 Subject: [PATCH 2/6] feat(remote): add RemoteControl policy engine with host tests Pure, host-testable engine for JWT-authenticated remote serial commands: replay protection, per-key rate limiting, command blacklist, target filtering, and authorization ordering. All JSON/base64/crypto/clock I/O is behind injected seams so the full pipeline is unit-tested under env:native (23 googletest cases) without MQTT or Ed25519. --- platformio.ini | 1 + src/helpers/RemoteControl.cpp | 158 ++++++++ src/helpers/RemoteControl.h | 204 ++++++++++ .../test_remote_control.cpp | 363 ++++++++++++++++++ 4 files changed, 726 insertions(+) create mode 100644 src/helpers/RemoteControl.cpp create mode 100644 src/helpers/RemoteControl.h create mode 100644 test/test_remote_control/test_remote_control.cpp diff --git a/platformio.ini b/platformio.ini index 5a8ec82d92..bd2b5b3267 100644 --- a/platformio.ini +++ b/platformio.ini @@ -175,6 +175,7 @@ build_src_filter = -<*> +<../src/Utils.cpp> +<../src/helpers/MQTTPayloadBuilder.cpp> + +<../src/helpers/RemoteControl.cpp> +<../src/Packet.cpp> lib_deps = google/googletest @ 1.17.0 diff --git a/src/helpers/RemoteControl.cpp b/src/helpers/RemoteControl.cpp new file mode 100644 index 0000000000..0b799229f0 --- /dev/null +++ b/src/helpers/RemoteControl.cpp @@ -0,0 +1,158 @@ +#include "RemoteControl.h" + +#include + +namespace { + +// Case-insensitive equality (ASCII). Avoids depending on . +bool ciEquals(const char* a, const char* b) { + while (*a && *b) { + if (tolower((unsigned char)*a) != tolower((unsigned char)*b)) return false; + a++; b++; + } + return *a == '\0' && *b == '\0'; +} + +// Parse exactly 64 hex chars into a RC_PUB_KEY_SIZE-byte key. false otherwise. +bool hexToKey(const char* hex, uint8_t* out) { + if (!hex || strlen(hex) != RC_PUB_KEY_SIZE * 2) return false; + for (int i = 0; i < RC_PUB_KEY_SIZE; i++) { + int hi = -1, lo = -1; + char c = hex[i * 2]; + char d = hex[i * 2 + 1]; + hi = (c >= '0' && c <= '9') ? c - '0' + : (c >= 'a' && c <= 'f') ? c - 'a' + 10 + : (c >= 'A' && c <= 'F') ? c - 'A' + 10 : -1; + lo = (d >= '0' && d <= '9') ? d - '0' + : (d >= 'a' && d <= 'f') ? d - 'a' + 10 + : (d >= 'A' && d <= 'F') ? d - 'A' + 10 : -1; + if (hi < 0 || lo < 0) return false; + out[i] = (uint8_t)((hi << 4) | lo); + } + return true; +} + +} // namespace + +RemoteControl::RemoteControl(RemoteControlCrypto* crypto, + RemoteControlAuthorizer* authorizer, + RemoteControlExecutor* executor, + RemoteControlClock* clock) + : _crypto(crypto), _authorizer(authorizer), _executor(executor), _clock(clock) {} + +RemoteControl::Outcome RemoteControl::error(const char* device_id, const char* request_id, + const char* message, char* out_jwt, + size_t out_jwt_size) { + unsigned long iat = _clock->unixNow(); + if (iat == 0) iat = _clock->millisNow() / 1000; // fallback before NTP sync + + RemoteCommandResponse resp; + resp.device_id = device_id; + resp.command = ""; + resp.request_id = request_id ? request_id : ""; + resp.success = false; + resp.response = message; + resp.iat = iat; + resp.exp = iat + RESPONSE_TTL_SEC; + + if (!_crypto->signResponse(resp, out_jwt, out_jwt_size)) { + return Outcome::SignFailed; + } + return Outcome::ResponseReady; +} + +RemoteControl::Outcome RemoteControl::process(const char* token, const char* device_id, + char* out_jwt, size_t out_jwt_size) { + if (!token || !device_id || !out_jwt || out_jwt_size == 0) { + return Outcome::SilentIgnore; + } + + RemoteCommandRequest req; + memset(&req, 0, sizeof(req)); + if (!_crypto->parseRequest(token, req)) { + return error(device_id, "", "Invalid or unparseable command token", out_jwt, out_jwt_size); + } + + // Target filtering: silently ignore commands addressed to another device. + if (req.target[0] != '\0' && !ciEquals(req.target, device_id)) { + return Outcome::SilentIgnore; + } + + if (req.command[0] == '\0') { + return error(device_id, req.nonce, "Invalid command", out_jwt, out_jwt_size); + } + + // Early replay check (before the expensive signature verify). + if (req.nonce[0] != '\0' && _nonces.isUsed(req.nonce)) { + return error(device_id, req.nonce, "Nonce already used - possible replay", out_jwt, out_jwt_size); + } + + // Early rate-limit check keyed on the claimed public key. + uint8_t claimed_key[RC_PUB_KEY_SIZE]; + bool have_claimed_key = hexToKey(req.public_key, claimed_key); + if (have_claimed_key && _rate_limiter.isRateLimited(claimed_key, _clock->millisNow())) { + return error(device_id, req.nonce, "Rate limit exceeded - too many commands", out_jwt, out_jwt_size); + } + + // Verify the signature and recover the actual signing key. + char signer_hex[65]; + signer_hex[0] = '\0'; + if (!_crypto->verifySignature(token, signer_hex, sizeof(signer_hex))) { + return error(device_id, req.nonce, "Invalid token signature", out_jwt, out_jwt_size); + } + + // The claimed key (if present) must match the key that actually signed. + if (req.public_key[0] != '\0' && !ciEquals(req.public_key, signer_hex)) { + return error(device_id, req.nonce, "Public key mismatch in token", out_jwt, out_jwt_size); + } + + uint8_t signer_key[RC_PUB_KEY_SIZE]; + if (!hexToKey(signer_hex, signer_key)) { + return error(device_id, req.nonce, "Invalid public key format in token", out_jwt, out_jwt_size); + } + + // Policy checks. + if (_blacklist.isBlacklisted(req.command)) { + return error(device_id, req.nonce, "Command not allowed via remote execution", out_jwt, out_jwt_size); + } + if (strncmp(req.command, "reboot", 6) == 0) { + return error(device_id, req.nonce, "Reboot not allowed via remote execution", out_jwt, out_jwt_size); + } + if (!_authorizer->authorize(signer_key, RC_PUB_KEY_SIZE)) { + return error(device_id, req.nonce, + _authorizer->useACL() ? "Unauthorized: public key not in ACL admin list" + : "Unauthorized: public key mismatch", + out_jwt, out_jwt_size); + } + + // Authorized: record the nonce so it cannot be replayed. + if (req.nonce[0] != '\0') { + _nonces.add(req.nonce); + } + + // Execute with a wall-clock timeout guard. + char reply[256]; + reply[0] = '\0'; + unsigned long start = _clock->millisNow(); + _executor->execute(req.command, reply, sizeof(reply)); + if ((_clock->millisNow() - start) > COMMAND_TIMEOUT_MS) { + return error(device_id, req.nonce, "Command execution timeout", out_jwt, out_jwt_size); + } + + unsigned long iat = _clock->unixNow(); + if (iat == 0) iat = _clock->millisNow() / 1000; + + RemoteCommandResponse resp; + resp.device_id = device_id; + resp.command = req.command; + resp.request_id = req.nonce; + resp.success = true; + resp.response = reply; + resp.iat = iat; + resp.exp = iat + RESPONSE_TTL_SEC; + + if (!_crypto->signResponse(resp, out_jwt, out_jwt_size)) { + return Outcome::SignFailed; + } + return Outcome::ResponseReady; +} diff --git a/src/helpers/RemoteControl.h b/src/helpers/RemoteControl.h new file mode 100644 index 0000000000..b6f7212be0 --- /dev/null +++ b/src/helpers/RemoteControl.h @@ -0,0 +1,204 @@ +#pragma once + +#include +#include +#include + +// Remote command execution policy engine. +// +// This class owns the security-critical *decisions* for JWT-authenticated +// remote serial commands (replay protection, rate limiting, command blacklist, +// target filtering, authorization ordering) while delegating all I/O, JSON, +// base64 and Ed25519 work to injected seams. That split lets the whole pipeline +// be unit-tested on the host without MQTT, ArduinoJson or crypto — see +// test/test_remote_control. The firmware wires the seams to JWTHelper, +// LocalIdentity, the ACL and the CLI in MQTTBridge. + +// Public-key length in bytes. Kept independent of mesh headers so this unit +// compiles standalone on the host; MQTTBridge static_asserts it == PUB_KEY_SIZE. +static const int RC_PUB_KEY_SIZE = 32; + +// Fields parsed from an *unverified* JWT payload, used for the cheap early +// checks that run before signature verification. +struct RemoteCommandRequest { + char command[256]; + char target[65]; // intended device id (hex pubkey); empty = broadcast + char nonce[48]; // UUID-style request id; empty = no replay protection + char public_key[65]; // signer pubkey claimed in the payload (hex); empty = absent +}; + +// Fields the engine hands to the crypto seam to build a signed response token. +struct RemoteCommandResponse { + const char* device_id; // our public key (hex) — the response signer + const char* command; // echoed command ("" for errors) + const char* request_id; // the originating nonce ("" if none) + bool success; + const char* response; // command reply, or error message + unsigned long iat; // issued-at (unix seconds) + unsigned long exp; // expiry (unix seconds) +}; + +// Seam: JSON / base64 / Ed25519. Firmware impl wraps JWTHelper + LocalIdentity. +class RemoteControlCrypto { +public: + virtual ~RemoteControlCrypto() {} + // Decode the payload segment and extract fields. false if the token is + // malformed or the payload cannot be parsed. No signature check here. + virtual bool parseRequest(const char* token, RemoteCommandRequest& out) = 0; + // Verify the token signature and extract the signing key (hex) into + // out_pubkey_hex (>= 65 bytes). false if the signature does not verify. + virtual bool verifySignature(const char* token, char* out_pubkey_hex, size_t out_size) = 0; + // Serialize + sign the response into out_jwt. false on failure. + virtual bool signResponse(const RemoteCommandResponse& resp, char* out_jwt, size_t out_size) = 0; +}; + +// Seam: authorization. Firmware impl combines the ACL admin list with the +// explicit admin-key preference, selected by the mqtt.useacl flag. +class RemoteControlAuthorizer { +public: + virtual ~RemoteControlAuthorizer() {} + virtual bool useACL() = 0; // for error-message wording + virtual bool authorize(const uint8_t* pubkey, size_t len) = 0; +}; + +// Seam: command execution. Firmware impl calls CommonCLI::handleCommand. +class RemoteControlExecutor { +public: + virtual ~RemoteControlExecutor() {} + virtual void execute(const char* command, char* reply, size_t reply_size) = 0; +}; + +// Seam: time. Firmware impl uses millis() and time(nullptr). +class RemoteControlClock { +public: + virtual ~RemoteControlClock() {} + virtual unsigned long millisNow() = 0; + virtual unsigned long unixNow() = 0; // unix seconds, 0 if the clock is unset +}; + +// Tracks recently-seen nonces to reject replays. Fixed-size circular buffer. +class RCNonceTracker { +public: + static const int MAX_NONCES = 10; + RCNonceTracker() : _index(0) { memset(_nonces, 0, sizeof(_nonces)); } + bool isUsed(const char* nonce) const { + for (int i = 0; i < MAX_NONCES; i++) { + if (_nonces[i][0] != '\0' && strcmp(_nonces[i], nonce) == 0) return true; + } + return false; + } + void add(const char* nonce) { + strncpy(_nonces[_index], nonce, sizeof(_nonces[_index]) - 1); + _nonces[_index][sizeof(_nonces[_index]) - 1] = '\0'; + _index = (_index + 1) % MAX_NONCES; + } +private: + char _nonces[MAX_NONCES][48]; + uint8_t _index; +}; + +// Per-public-key minimum interval between commands. Evicts the oldest key when +// full. `now` is supplied by the caller so this stays pure/testable. +class RCRateLimiter { +public: + static const int MAX_TRACKED_KEYS = 20; + static const unsigned long MIN_INTERVAL_MS = 1000; + RCRateLimiter() : _num_tracked(0) { + memset(_last_ms, 0, sizeof(_last_ms)); + memset(_keys, 0, sizeof(_keys)); + } + bool isRateLimited(const uint8_t* pubkey, unsigned long now) { + int idx = findKey(pubkey); + if (idx < 0) idx = addKey(pubkey); + if (idx < 0) return false; + if (_last_ms[idx] != 0 && (now - _last_ms[idx]) < MIN_INTERVAL_MS) return true; + _last_ms[idx] = now; + return false; + } +private: + int findKey(const uint8_t* pubkey) const { + for (int i = 0; i < _num_tracked; i++) { + if (memcmp(_keys[i], pubkey, RC_PUB_KEY_SIZE) == 0) return i; + } + return -1; + } + int addKey(const uint8_t* pubkey) { + int idx; + if (_num_tracked >= MAX_TRACKED_KEYS) { + idx = 0; + for (int i = 1; i < MAX_TRACKED_KEYS; i++) { + if (_last_ms[i] < _last_ms[idx]) idx = i; + } + } else { + idx = _num_tracked++; + } + memcpy(_keys[idx], pubkey, RC_PUB_KEY_SIZE); + _last_ms[idx] = 0; + return idx; + } + unsigned long _last_ms[MAX_TRACKED_KEYS]; + uint8_t _keys[MAX_TRACKED_KEYS][RC_PUB_KEY_SIZE]; + uint8_t _num_tracked; +}; + +// Commands that may never run remotely, matched by prefix. +class RCCommandBlacklist { +public: + RCCommandBlacklist() : _count(0) { + add("get wifi.pwd"); // Wi-Fi password + add("set mqtt.admin"); // admin key (security-critical) + } + bool add(const char* prefix) { + if (_count >= MAX_ENTRIES) return false; + _entries[_count++] = prefix; + return true; + } + bool isBlacklisted(const char* command) const { + for (int i = 0; i < _count; i++) { + if (strncmp(command, _entries[i], strlen(_entries[i])) == 0) return true; + } + return false; + } +private: + static const int MAX_ENTRIES = 20; + const char* _entries[MAX_ENTRIES]; + int _count; +}; + +class RemoteControl { +public: + enum class Outcome { + SilentIgnore, // not addressed to us / nothing to send + ResponseReady, // out_jwt holds a signed response (success or error) to publish + SignFailed, // response could not be built (log only) + }; + + RemoteControl(RemoteControlCrypto* crypto, + RemoteControlAuthorizer* authorizer, + RemoteControlExecutor* executor, + RemoteControlClock* clock); + + // Run the full pipeline for one inbound command token. `device_id` is our + // public key in hex (used for target matching and as the response signer). + Outcome process(const char* token, const char* device_id, char* out_jwt, size_t out_jwt_size); + + // Longest a remote command may run before its reply is rejected. + static const unsigned long COMMAND_TIMEOUT_MS = 5000; + // Response token lifetime. + static const unsigned long RESPONSE_TTL_SEC = 60; + + RCCommandBlacklist& blacklist() { return _blacklist; } + +private: + Outcome error(const char* device_id, const char* request_id, const char* message, + char* out_jwt, size_t out_jwt_size); + + RemoteControlCrypto* _crypto; + RemoteControlAuthorizer* _authorizer; + RemoteControlExecutor* _executor; + RemoteControlClock* _clock; + + RCNonceTracker _nonces; + RCRateLimiter _rate_limiter; + RCCommandBlacklist _blacklist; +}; diff --git a/test/test_remote_control/test_remote_control.cpp b/test/test_remote_control/test_remote_control.cpp new file mode 100644 index 0000000000..b2d6d93859 --- /dev/null +++ b/test/test_remote_control/test_remote_control.cpp @@ -0,0 +1,363 @@ +#include + +#include +#include + +#include "helpers/RemoteControl.h" + +namespace { + +// A 64-hex-char public key (RC_PUB_KEY_SIZE bytes). +const char* KEY_A = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"; +const char* KEY_B = "BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB"; +const char* DEVICE = "1234567890ABCDEF1234567890ABCDEF1234567890ABCDEF1234567890ABCDEF"; + +// The fake "token" is a caret-delimited string the fake crypto understands: +// command ^ target ^ nonce ^ payloadPubkey ^ signerPubkey ^ signatureValid(0/1) +// This lets one string drive both parseRequest() and verifySignature() without +// any real JSON / base64 / crypto. +std::string makeToken(const std::string& cmd, const std::string& target, + const std::string& nonce, const std::string& payload_key, + const std::string& signer_key, bool valid) { + return cmd + "^" + target + "^" + nonce + "^" + payload_key + "^" + signer_key + + "^" + (valid ? "1" : "0"); +} + +std::vector split(const std::string& s, char sep) { + std::vector out; + std::string cur; + for (char c : s) { + if (c == sep) { out.push_back(cur); cur.clear(); } + else cur += c; + } + out.push_back(cur); + return out; +} + +class FakeClock : public RemoteControlClock { +public: + unsigned long ms = 1000; + unsigned long unix = 1700000000; // some time after 2001 + unsigned long millisNow() override { return ms; } + unsigned long unixNow() override { return unix; } +}; + +class FakeCrypto : public RemoteControlCrypto { +public: + bool sign_should_succeed = true; + bool malformed = false; + + // Recorded from the last signResponse(). + int sign_calls = 0; + bool last_success = false; + std::string last_response; + std::string last_command; + std::string last_request_id; + std::string last_device_id; + + bool parseRequest(const char* token, RemoteCommandRequest& out) override { + if (malformed) return false; + auto f = split(token, '^'); + if (f.size() < 6) return false; + strncpy(out.command, f[0].c_str(), sizeof(out.command) - 1); + strncpy(out.target, f[1].c_str(), sizeof(out.target) - 1); + strncpy(out.nonce, f[2].c_str(), sizeof(out.nonce) - 1); + strncpy(out.public_key, f[3].c_str(), sizeof(out.public_key) - 1); + return true; + } + + bool verifySignature(const char* token, char* out_pubkey_hex, size_t out_size) override { + auto f = split(token, '^'); + if (f.size() < 6) return false; + bool valid = f[5] == "1"; + if (!valid) return false; + strncpy(out_pubkey_hex, f[4].c_str(), out_size - 1); + out_pubkey_hex[out_size - 1] = '\0'; + return true; + } + + bool signResponse(const RemoteCommandResponse& resp, char* out_jwt, size_t out_size) override { + sign_calls++; + last_success = resp.success; + last_response = resp.response ? resp.response : ""; + last_command = resp.command ? resp.command : ""; + last_request_id = resp.request_id ? resp.request_id : ""; + last_device_id = resp.device_id ? resp.device_id : ""; + if (!sign_should_succeed) return false; + snprintf(out_jwt, out_size, "JWT:%d:%s", resp.success ? 1 : 0, last_response.c_str()); + return true; + } +}; + +class FakeAuthorizer : public RemoteControlAuthorizer { +public: + bool use_acl = true; + bool authorize_result = true; + int authorize_calls = 0; + bool useACL() override { return use_acl; } + bool authorize(const uint8_t* pubkey, size_t len) override { + authorize_calls++; + return authorize_result; + } +}; + +class FakeExecutor : public RemoteControlExecutor { +public: + FakeClock* clock = nullptr; + unsigned long advance_ms = 0; // simulate a slow command + std::string reply = "ok"; + int calls = 0; + std::string last_command; + void execute(const char* command, char* out, size_t out_size) override { + calls++; + last_command = command; + strncpy(out, reply.c_str(), out_size - 1); + out[out_size - 1] = '\0'; + if (clock) clock->ms += advance_ms; + } +}; + +struct Harness { + FakeClock clock; + FakeCrypto crypto; + FakeAuthorizer authz; + FakeExecutor exec; + RemoteControl rc; + char out[1024]; + + Harness() : rc(&crypto, &authz, &exec, &clock) { + exec.clock = &clock; + out[0] = '\0'; + } + RemoteControl::Outcome run(const std::string& token) { + return rc.process(token.c_str(), DEVICE, out, sizeof(out)); + } +}; + +using Outcome = RemoteControl::Outcome; + +TEST(RemoteControl, HappyPathExecutesAndSignsSuccess) { + Harness h; + auto o = h.run(makeToken("get bat", "", "n1", KEY_A, KEY_A, true)); + EXPECT_EQ(o, Outcome::ResponseReady); + EXPECT_EQ(h.exec.calls, 1); + EXPECT_EQ(h.exec.last_command, "get bat"); + EXPECT_TRUE(h.crypto.last_success); + EXPECT_EQ(h.crypto.last_response, "ok"); + EXPECT_EQ(h.crypto.last_request_id, "n1"); + EXPECT_EQ(h.crypto.last_device_id, DEVICE); +} + +TEST(RemoteControl, TargetForAnotherDeviceIsSilentlyIgnored) { + Harness h; + auto o = h.run(makeToken("get bat", KEY_B, "n1", KEY_A, KEY_A, true)); + EXPECT_EQ(o, Outcome::SilentIgnore); + EXPECT_EQ(h.exec.calls, 0); + EXPECT_EQ(h.crypto.sign_calls, 0); +} + +TEST(RemoteControl, TargetMatchesDeviceCaseInsensitive) { + Harness h; + std::string lower_device = "1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef"; + auto o = h.run(makeToken("get bat", lower_device, "n1", KEY_A, KEY_A, true)); + EXPECT_EQ(o, Outcome::ResponseReady); + EXPECT_EQ(h.exec.calls, 1); +} + +TEST(RemoteControl, EmptyCommandIsRejected) { + Harness h; + auto o = h.run(makeToken("", "", "n1", KEY_A, KEY_A, true)); + EXPECT_EQ(o, Outcome::ResponseReady); + EXPECT_FALSE(h.crypto.last_success); + EXPECT_EQ(h.exec.calls, 0); +} + +TEST(RemoteControl, MalformedTokenProducesSignedError) { + Harness h; + h.crypto.malformed = true; + auto o = h.run("garbage"); + EXPECT_EQ(o, Outcome::ResponseReady); + EXPECT_FALSE(h.crypto.last_success); + EXPECT_EQ(h.crypto.last_request_id, ""); + EXPECT_EQ(h.exec.calls, 0); +} + +TEST(RemoteControl, InvalidSignatureIsRejected) { + Harness h; + auto o = h.run(makeToken("get bat", "", "n1", KEY_A, KEY_A, false)); + EXPECT_EQ(o, Outcome::ResponseReady); + EXPECT_FALSE(h.crypto.last_success); + EXPECT_NE(h.crypto.last_response.find("signature"), std::string::npos); + EXPECT_EQ(h.exec.calls, 0); +} + +TEST(RemoteControl, PayloadKeyMustMatchSigner) { + Harness h; + auto o = h.run(makeToken("get bat", "", "n1", KEY_A, KEY_B, true)); + EXPECT_EQ(o, Outcome::ResponseReady); + EXPECT_FALSE(h.crypto.last_success); + EXPECT_NE(h.crypto.last_response.find("mismatch"), std::string::npos); + EXPECT_EQ(h.exec.calls, 0); +} + +TEST(RemoteControl, NonceReplayIsRejectedOnSecondUse) { + Harness h; + EXPECT_EQ(h.run(makeToken("get bat", "", "dup", KEY_A, KEY_A, true)), Outcome::ResponseReady); + EXPECT_TRUE(h.crypto.last_success); + // Same nonce again -> rejected before verification/execution. + auto o = h.run(makeToken("get bat", "", "dup", KEY_A, KEY_A, true)); + EXPECT_EQ(o, Outcome::ResponseReady); + EXPECT_FALSE(h.crypto.last_success); + EXPECT_NE(h.crypto.last_response.find("replay"), std::string::npos); + EXPECT_EQ(h.exec.calls, 1); // only the first executed +} + +TEST(RemoteControl, NonceNotBurnedWhenAuthorizationFails) { + Harness h; + h.authz.authorize_result = false; + EXPECT_EQ(h.run(makeToken("get bat", "", "n1", KEY_A, KEY_A, true)), Outcome::ResponseReady); + EXPECT_FALSE(h.crypto.last_success); + // A later authorized command reusing the nonce must still succeed. + h.authz.authorize_result = true; + h.clock.ms += 2000; // avoid rate limit + auto o = h.run(makeToken("get bat", "", "n1", KEY_A, KEY_A, true)); + EXPECT_EQ(o, Outcome::ResponseReady); + EXPECT_TRUE(h.crypto.last_success); +} + +TEST(RemoteControl, RateLimitRejectsRapidSecondCommand) { + Harness h; + EXPECT_EQ(h.run(makeToken("get bat", "", "n1", KEY_A, KEY_A, true)), Outcome::ResponseReady); + EXPECT_TRUE(h.crypto.last_success); + // Different nonce so replay does not mask the rate-limit path; same millis. + auto o = h.run(makeToken("get bat", "", "n2", KEY_A, KEY_A, true)); + EXPECT_EQ(o, Outcome::ResponseReady); + EXPECT_FALSE(h.crypto.last_success); + EXPECT_NE(h.crypto.last_response.find("Rate limit"), std::string::npos); + EXPECT_EQ(h.exec.calls, 1); +} + +TEST(RemoteControl, RateLimitClearsAfterInterval) { + Harness h; + EXPECT_EQ(h.run(makeToken("get bat", "", "n1", KEY_A, KEY_A, true)), Outcome::ResponseReady); + h.clock.ms += 1500; // past MIN_INTERVAL_MS + auto o = h.run(makeToken("get bat", "", "n2", KEY_A, KEY_A, true)); + EXPECT_EQ(o, Outcome::ResponseReady); + EXPECT_TRUE(h.crypto.last_success); + EXPECT_EQ(h.exec.calls, 2); +} + +TEST(RemoteControl, RateLimitIsPerKey) { + Harness h; + EXPECT_EQ(h.run(makeToken("get bat", "", "n1", KEY_A, KEY_A, true)), Outcome::ResponseReady); + auto o = h.run(makeToken("get bat", "", "n2", KEY_B, KEY_B, true)); // different key, same millis + EXPECT_EQ(o, Outcome::ResponseReady); + EXPECT_TRUE(h.crypto.last_success); +} + +TEST(RemoteControl, DefaultBlacklistBlocksWifiPassword) { + Harness h; + auto o = h.run(makeToken("get wifi.pwd", "", "n1", KEY_A, KEY_A, true)); + EXPECT_EQ(o, Outcome::ResponseReady); + EXPECT_FALSE(h.crypto.last_success); + EXPECT_NE(h.crypto.last_response.find("not allowed"), std::string::npos); + EXPECT_EQ(h.exec.calls, 0); +} + +TEST(RemoteControl, DefaultBlacklistBlocksSetMqttAdmin) { + Harness h; + auto o = h.run(makeToken("set mqtt.admin DEADBEEF", "", "n1", KEY_A, KEY_A, true)); + EXPECT_EQ(o, Outcome::ResponseReady); + EXPECT_FALSE(h.crypto.last_success); + EXPECT_EQ(h.exec.calls, 0); +} + +TEST(RemoteControl, RebootIsRejected) { + Harness h; + auto o = h.run(makeToken("reboot", "", "n1", KEY_A, KEY_A, true)); + EXPECT_EQ(o, Outcome::ResponseReady); + EXPECT_FALSE(h.crypto.last_success); + EXPECT_NE(h.crypto.last_response.find("Reboot"), std::string::npos); + EXPECT_EQ(h.exec.calls, 0); +} + +TEST(RemoteControl, UnauthorizedMessageMentionsAclWhenUsingAcl) { + Harness h; + h.authz.use_acl = true; + h.authz.authorize_result = false; + h.run(makeToken("get bat", "", "n1", KEY_A, KEY_A, true)); + EXPECT_FALSE(h.crypto.last_success); + EXPECT_NE(h.crypto.last_response.find("ACL"), std::string::npos); +} + +TEST(RemoteControl, UnauthorizedMessageMentionsKeyWhenNotUsingAcl) { + Harness h; + h.authz.use_acl = false; + h.authz.authorize_result = false; + h.run(makeToken("get bat", "", "n1", KEY_A, KEY_A, true)); + EXPECT_FALSE(h.crypto.last_success); + EXPECT_EQ(h.crypto.last_response.find("ACL"), std::string::npos); +} + +TEST(RemoteControl, SignFailureReportsSignFailed) { + Harness h; + h.crypto.sign_should_succeed = false; + auto o = h.run(makeToken("get bat", "", "n1", KEY_A, KEY_A, true)); + EXPECT_EQ(o, Outcome::SignFailed); +} + +TEST(RemoteControl, SlowCommandTimesOut) { + Harness h; + h.exec.advance_ms = RemoteControl::COMMAND_TIMEOUT_MS + 1000; + auto o = h.run(makeToken("get bat", "", "n1", KEY_A, KEY_A, true)); + EXPECT_EQ(o, Outcome::ResponseReady); + EXPECT_FALSE(h.crypto.last_success); + EXPECT_NE(h.crypto.last_response.find("timeout"), std::string::npos); + EXPECT_EQ(h.exec.calls, 1); // it ran, but the reply is rejected +} + +TEST(RemoteControl, CustomBlacklistEntryIsEnforced) { + Harness h; + h.rc.blacklist().add("set freq"); + auto o = h.run(makeToken("set freq 915", "", "n1", KEY_A, KEY_A, true)); + EXPECT_FALSE(h.crypto.last_success); + EXPECT_EQ(h.exec.calls, 0); +} + +// --- direct unit tests of the pure structures --- + +TEST(RCNonceTracker, DetectsReplayAndWrapsAround) { + RCNonceTracker t; + EXPECT_FALSE(t.isUsed("a")); + t.add("a"); + EXPECT_TRUE(t.isUsed("a")); + // Fill past capacity; "a" should eventually be evicted. + for (int i = 0; i < RCNonceTracker::MAX_NONCES; i++) { + char buf[16]; + snprintf(buf, sizeof(buf), "x%d", i); + t.add(buf); + } + EXPECT_FALSE(t.isUsed("a")); +} + +TEST(RCRateLimiter, LimitsWithinIntervalOnly) { + RCRateLimiter r; + uint8_t key[RC_PUB_KEY_SIZE] = {0}; + EXPECT_FALSE(r.isRateLimited(key, 1000)); + EXPECT_TRUE(r.isRateLimited(key, 1500)); + EXPECT_FALSE(r.isRateLimited(key, 2000)); +} + +TEST(RCCommandBlacklist, MatchesByPrefix) { + RCCommandBlacklist b; + EXPECT_TRUE(b.isBlacklisted("get wifi.pwd")); + EXPECT_TRUE(b.isBlacklisted("get wifi.pwd extra")); + EXPECT_FALSE(b.isBlacklisted("get bat")); +} + +} // namespace + +int main(int argc, char** argv) { + ::testing::InitGoogleTest(&argc, argv); + return RUN_ALL_TESTS(); +} From ffdf48b5a96a1de422395a18011d3a10f6ff7c93 Mon Sep 17 00:00:00 2001 From: agessaman Date: Tue, 21 Jul 2026 09:38:38 -0700 Subject: [PATCH 3/6] feat(mqtt-prefs): append remote-control fields to MQTTPrefs (v1) Adds mqtt_remote_enabled (global master), mqtt_use_acl, per-slot mqtt_slot_remote_enabled[], and mqtt_admin_public_key to the /mqtt_prefs tail. Introduces the kV1PreRemotePayloadSize (2864) decode checkpoint so a pre-remote payload still loads as Current with the remote fields defaulting (master off, ACL on, per-slot on). Payload version stays 1; a forward file (2940) is held, not discarded, by older firmware. New host codec tests cover the pre-remote migration and the full round-trip. --- src/helpers/MQTTDefaults.h | 11 +++ src/helpers/MQTTPrefsCodec.h | 7 ++ src/helpers/MQTTPrefsStorage.h | 23 +++++- .../test_mqtt_prefs_codec.cpp | 72 +++++++++++++++++++ 4 files changed, 110 insertions(+), 3 deletions(-) diff --git a/src/helpers/MQTTDefaults.h b/src/helpers/MQTTDefaults.h index 6b4c9ef159..513ed22619 100644 --- a/src/helpers/MQTTDefaults.h +++ b/src/helpers/MQTTDefaults.h @@ -105,6 +105,17 @@ static inline void applyMQTTDefaults(MQTTPrefs* prefs) { // (not 0) so an in-lineage upgrade from a pre-neighbors payload is sane. prefs->mqtt_neighbors_enabled = 0; prefs->mqtt_neighbors_interval = MQTT_NEIGHBORS_DEFAULT_INTERVAL_MS; + + // Remote control: master off (must be opted in), ACL authorization on, and + // every slot enabled so flipping the master on serves all brokers by default. + // A device upgrading from a pre-remote payload keeps these defaults, so remote + // control stays off until the operator enables it. + prefs->mqtt_remote_enabled = 0; + prefs->mqtt_use_acl = 1; + for (int i = 0; i < MQTT_PREFS_SLOT_COUNT; i++) { + prefs->mqtt_slot_remote_enabled[i] = 1; + } + prefs->mqtt_admin_public_key[0] = '\0'; } #endif // WITH_MQTT_BRIDGE diff --git a/src/helpers/MQTTPrefsCodec.h b/src/helpers/MQTTPrefsCodec.h index 715d33f260..51f7cac5df 100644 --- a/src/helpers/MQTTPrefsCodec.h +++ b/src/helpers/MQTTPrefsCodec.h @@ -37,6 +37,7 @@ struct DecodePlan { static const size_t kV1PreObserverPayloadSize = MQTT_PREFS_V1_PRE_OBSERVER_PAYLOAD_SIZE; static const size_t kV1PreNeighborsPayloadSize = MQTT_PREFS_V1_PRE_NEIGHBORS_PAYLOAD_SIZE; +static const size_t kV1PreRemotePayloadSize = MQTT_PREFS_V1_PRE_REMOTE_PAYLOAD_SIZE; static const size_t kV1BaselinePayloadSize = MQTT_PREFS_V1_FULL_PAYLOAD_SIZE; static const size_t kEncodedSize = sizeof(MQTTPrefsHeader) + kV1BaselinePayloadSize; @@ -100,6 +101,12 @@ inline DecodePlan classify(const uint8_t* prefix, size_t prefix_read, size_t fil if (header.payload_len == kV1BaselinePayloadSize) { return {Source::Current, false, false, true, kV1BaselinePayloadSize}; } + if (header.payload_len == kV1PreRemotePayloadSize) { + // Written by observer/webconfig firmware before the remote-control tail + // existed. Everything through the neighbors tail is present; only the + // remote fields are missing, so they load with their defaults. + return {Source::Current, false, false, true, kV1PreRemotePayloadSize}; + } if (header.payload_len == kV1PreNeighborsPayloadSize) { // Written by observer/webconfig firmware before the neighbors tail // existed. The observer fields ARE present; only the neighbors tail is diff --git a/src/helpers/MQTTPrefsStorage.h b/src/helpers/MQTTPrefsStorage.h index ffa90c58b6..67ede3508e 100644 --- a/src/helpers/MQTTPrefsStorage.h +++ b/src/helpers/MQTTPrefsStorage.h @@ -117,6 +117,15 @@ struct MQTTPrefs { // interchangeable (see the offsetof static_asserts below). uint8_t mqtt_neighbors_enabled; uint32_t mqtt_neighbors_interval; + + // Remote serial command execution (JWT-authenticated). Appended at the tail + // so a shorter pre-remote /mqtt_prefs still loads with these at their + // defaults. mqtt_remote_enabled is the global master (kill switch); a slot + // serves remote commands only when the master AND its per-slot flag are set. + uint8_t mqtt_remote_enabled; // global master, default 0 (off) + uint8_t mqtt_use_acl; // authorize via ACL admin list, default 1 + uint8_t mqtt_slot_remote_enabled[MQTT_PREFS_SLOT_COUNT]; // per-slot enable, default 1 + char mqtt_admin_public_key[65]; // explicit admin key when ACL is off }; // Neighbor discovery is scheduled with the wrap-safe millis() helpers, whose @@ -129,14 +138,16 @@ static const uint32_t MQTT_NEIGHBORS_MIN_INTERVAL_MS = MQTT_NEIGHBORS_MIN_INTERV static const uint32_t MQTT_NEIGHBORS_MAX_INTERVAL_MS = MQTT_NEIGHBORS_MAX_INTERVAL_HOURS * 3600000UL; static const uint32_t MQTT_NEIGHBORS_DEFAULT_INTERVAL_MS = MQTT_NEIGHBORS_DEFAULT_INTERVAL_HOURS * 3600000UL; -// Version-1 has three payload layouts this firmware can decode. Never infer a +// Version-1 has four payload layouts this firmware can decode. Never infer a // compatible payload from an arbitrary shorter size: raw prefs have no checksum. // - PRE_OBSERVER (2736): stops before the observer tail (snmp_*/alert_*). // - PRE_NEIGHBORS (2860): full observer tail, no neighbors fields yet. -// - FULL (2864): current baseline, with the neighbors tail. +// - PRE_REMOTE (2864): neighbors tail present, no remote-control fields yet. +// - FULL (2940): current baseline, with the remote-control tail. static const size_t MQTT_PREFS_V1_PRE_OBSERVER_PAYLOAD_SIZE = 2736; static const size_t MQTT_PREFS_V1_PRE_NEIGHBORS_PAYLOAD_SIZE = 2860; -static const size_t MQTT_PREFS_V1_FULL_PAYLOAD_SIZE = 2864; +static const size_t MQTT_PREFS_V1_PRE_REMOTE_PAYLOAD_SIZE = 2864; +static const size_t MQTT_PREFS_V1_FULL_PAYLOAD_SIZE = 2940; // /mqtt_prefs starts with a self-describing 8-byte header. Headerless files // are deployed legacy layouts and continue to be distinguished by size. @@ -266,6 +277,12 @@ static_assert(offsetof(MQTTPrefs, mqtt_neighbors_enabled) == 2857, "neighbors enable flag must sit at the flex-compatible offset"); static_assert(offsetof(MQTTPrefs, mqtt_neighbors_interval) == MQTT_PREFS_V1_PRE_NEIGHBORS_PAYLOAD_SIZE, "neighbors interval offset must equal the pre-neighbors payload size"); +// The remote-control tail is appended after the neighbors fields; the first +// remote field begins exactly at the pre-remote payload size (2864) so a file +// written before it existed stops right here and the remote fields keep their +// defaults (master off, ACL on, per-slot on). +static_assert(offsetof(MQTTPrefs, mqtt_remote_enabled) == MQTT_PREFS_V1_PRE_REMOTE_PAYLOAD_SIZE, + "remote-control tail must begin at the pre-remote payload size"); static_assert(sizeof(OldMQTTPrefs) == 472, "frozen pre-slot /mqtt_prefs layout changed"); static_assert(sizeof(PreWifiPowerOldMQTTPrefs) == 472, "frozen pre-WiFi-power /mqtt_prefs layout changed"); static_assert(offsetof(OldMQTTPrefs, wifi_power_save) == 144, diff --git a/test/test_mqtt_prefs_codec/test_mqtt_prefs_codec.cpp b/test/test_mqtt_prefs_codec/test_mqtt_prefs_codec.cpp index 6d1fba4baa..190b4274a9 100644 --- a/test/test_mqtt_prefs_codec/test_mqtt_prefs_codec.cpp +++ b/test/test_mqtt_prefs_codec/test_mqtt_prefs_codec.cpp @@ -306,6 +306,78 @@ TEST(MQTTPrefsCodec, PreNeighborsV1PayloadLoadsObserverFieldsAndDefaultsNeighbor EXPECT_EQ(MQTT_NEIGHBORS_DEFAULT_INTERVAL_MS, loaded.mqtt_neighbors_interval); } +TEST(MQTTPrefsCodec, PreRemoteV1PayloadLoadsNeighborsAndDefaultsRemoteTail) { + // A /mqtt_prefs written by observer/webconfig firmware before the + // remote-control tail existed: everything through the neighbors tail present, + // 2864-byte v1 payload. It must load as Current with the remote fields left at + // the caller's defaults (master off, ACL on, per-slot on, empty admin key). + MQTTPrefs source = defaults(); + strncpy(source.mqtt_origin, "pre-remote-node", sizeof(source.mqtt_origin) - 1); + source.mqtt_neighbors_enabled = 1; + source.mqtt_neighbors_interval = MQTT_NEIGHBORS_MAX_INTERVAL_MS; + // Sentinels that must NOT survive a 2864-byte read. + source.mqtt_remote_enabled = 1; + source.mqtt_use_acl = 0; + source.mqtt_slot_remote_enabled[0] = 0; + strncpy(source.mqtt_admin_public_key, "DEADBEEF", sizeof(source.mqtt_admin_public_key) - 1); + + std::vector bytes(sizeof(MQTTPrefsHeader) + Codec::kV1PreRemotePayloadSize, 0); + writeHeader(&bytes, MQTT_PREFS_VERSION, + static_cast(Codec::kV1PreRemotePayloadSize)); + memcpy(bytes.data() + sizeof(MQTTPrefsHeader), &source, Codec::kV1PreRemotePayloadSize); + + const Codec::DecodePlan plan = classify(bytes); + ASSERT_EQ(Codec::Source::Current, plan.source); + ASSERT_EQ(Codec::kV1PreRemotePayloadSize, plan.payload_len); + ASSERT_FALSE(plan.preserve_file); + ASSERT_TRUE(plan.observer_fields_present); + + // Simulate applyMQTTDefaults(): the remote tail defaults the loader relies on. + MQTTPrefs loaded = defaults(); + loaded.mqtt_remote_enabled = 0; + loaded.mqtt_use_acl = 1; + for (int i = 0; i < MQTT_PREFS_SLOT_COUNT; ++i) loaded.mqtt_slot_remote_enabled[i] = 1; + loaded.mqtt_admin_public_key[0] = '\0'; + memcpy(&loaded, bytes.data() + sizeof(MQTTPrefsHeader), plan.payload_len); + + // Neighbors fields sit before 2864, so they load from the file. + EXPECT_STREQ("pre-remote-node", loaded.mqtt_origin); + EXPECT_EQ(1u, loaded.mqtt_neighbors_enabled); + EXPECT_EQ(MQTT_NEIGHBORS_MAX_INTERVAL_MS, loaded.mqtt_neighbors_interval); + // Remote fields sit beyond the read, so they keep their defaults. + EXPECT_EQ(0u, loaded.mqtt_remote_enabled); + EXPECT_EQ(1u, loaded.mqtt_use_acl); + EXPECT_EQ(1u, loaded.mqtt_slot_remote_enabled[0]); + EXPECT_STREQ("", loaded.mqtt_admin_public_key); +} + +TEST(MQTTPrefsCodec, FullPayloadRoundTripsRemoteControlFields) { + MQTTPrefs source = defaults(); + source.mqtt_remote_enabled = 1; + source.mqtt_use_acl = 0; + source.mqtt_slot_remote_enabled[0] = 0; + source.mqtt_slot_remote_enabled[3] = 1; + strncpy(source.mqtt_admin_public_key, + "1234567890ABCDEF1234567890ABCDEF1234567890ABCDEF1234567890ABCDEF", + sizeof(source.mqtt_admin_public_key) - 1); + + std::vector bytes(Codec::kEncodedSize); + ASSERT_EQ(Codec::kEncodedSize, Codec::encode(source, bytes.data(), bytes.size())); + + const Codec::DecodePlan plan = classify(bytes); + ASSERT_EQ(Codec::Source::Current, plan.source); + ASSERT_EQ(Codec::kV1BaselinePayloadSize, plan.payload_len); + MQTTPrefs loaded = defaults(); + memcpy(&loaded, bytes.data() + sizeof(MQTTPrefsHeader), plan.payload_len); + + EXPECT_EQ(1u, loaded.mqtt_remote_enabled); + EXPECT_EQ(0u, loaded.mqtt_use_acl); + EXPECT_EQ(0u, loaded.mqtt_slot_remote_enabled[0]); + EXPECT_EQ(1u, loaded.mqtt_slot_remote_enabled[3]); + EXPECT_STREQ("1234567890ABCDEF1234567890ABCDEF1234567890ABCDEF1234567890ABCDEF", + loaded.mqtt_admin_public_key); +} + TEST(MQTTPrefsCodec, CorruptOrShortVersionedInputsArePreserved) { Codec::DecodePlan plan = Codec::classify(nullptr, 0, 0); EXPECT_EQ(Codec::Source::Corrupt, plan.source); From e0cea5694f6c8109dc1ed860cff3d8d2fb523989 Mon Sep 17 00:00:00 2001 From: agessaman Date: Tue, 21 Jul 2026 09:42:50 -0700 Subject: [PATCH 4/6] feat(cli): add remote-control config commands Global: set/get mqtt.remote (master kill switch), mqtt.useacl, mqtt.admin (admin key readable over serial only). Per-slot: set/get mqttN.remote, following the existing mqttN.* slot-command convention. Setters only persist; the bridge reconciles command subscriptions live, so no WSS restart is needed. --- src/helpers/CommonCLI_Observer.cpp | 47 ++++++++++++++++++++++++++++++ 1 file changed, 47 insertions(+) diff --git a/src/helpers/CommonCLI_Observer.cpp b/src/helpers/CommonCLI_Observer.cpp index 6bd3a4e04d..2bb647f703 100644 --- a/src/helpers/CommonCLI_Observer.cpp +++ b/src/helpers/CommonCLI_Observer.cpp @@ -529,6 +529,13 @@ bool CommonCLI::handleObserverSetCmd(uint32_t sender_timestamp, const char* conf savePrefs(); _callbacks->restartBridgeSlot(slot); sprintf(reply, "OK - slot %d JWT audience cleared (using username/password auth)", slot + 1); + } else if (memcmp(subcmd, "remote ", 7) == 0) { + // Per-slot remote-command enable. Effective only while the global master + // (mqtt.remote) is on; the bridge reconciles subscriptions live. + _mqtt_prefs.mqtt_slot_remote_enabled[slot] = memcmp(&subcmd[7], "on", 2) == 0; + savePrefs(); + sprintf(reply, "OK - slot %d remote %s", slot + 1, + _mqtt_prefs.mqtt_slot_remote_enabled[slot] ? "on" : "off"); } else { sprintf(reply, "unknown config: %s", config); } @@ -572,6 +579,30 @@ bool CommonCLI::handleObserverSetCmd(uint32_t sender_timestamp, const char* conf StrHelper::strncpy(_mqtt_prefs.mqtt_email, &config[11], sizeof(_mqtt_prefs.mqtt_email)); savePrefs(); strcpy(reply, "OK"); + } else if (memcmp(config, "mqtt.remote ", 12) == 0) { + // Global master / kill switch for remote command execution. The bridge task + // reconciles subscriptions live, so no restart is needed; turning this off + // unsubscribes every slot and drops any in-flight command on the next pass. + _mqtt_prefs.mqtt_remote_enabled = memcmp(&config[12], "on", 2) == 0; + savePrefs(); + sprintf(reply, "OK - remote control %s", _mqtt_prefs.mqtt_remote_enabled ? "on" : "off"); + } else if (memcmp(config, "mqtt.useacl ", 12) == 0) { + _mqtt_prefs.mqtt_use_acl = memcmp(&config[12], "on", 2) == 0; + savePrefs(); + sprintf(reply, "OK - remote auth via %s", _mqtt_prefs.mqtt_use_acl ? "ACL admin list" : "admin key"); + } else if (memcmp(config, "mqtt.admin ", 11) == 0) { + const char* admin_key = &config[11]; + if (admin_key[0] == '\0' || strcmp(admin_key, "0") == 0) { + _mqtt_prefs.mqtt_admin_public_key[0] = '\0'; + savePrefs(); + strcpy(reply, "OK - admin key cleared"); + } else if (mqttOwnerKeyValid(admin_key)) { + StrHelper::strncpy(_mqtt_prefs.mqtt_admin_public_key, admin_key, sizeof(_mqtt_prefs.mqtt_admin_public_key)); + savePrefs(); + strcpy(reply, "OK"); + } else { + strcpy(reply, "Error: public key must be 64 hex characters (32 bytes)"); + } #endif } else if (memcmp(config, "alert ", 6) == 0) { // set alert on|off @@ -828,6 +859,20 @@ bool CommonCLI::handleObserverGetCmd(uint32_t sender_timestamp, const char* conf #endif } else if (memcmp(config, "mqtt.ntp", 8) == 0 && (config[8] == '\0' || config[8] == ' ')) { sprintf(reply, "> %s", MQTTBridge::effectiveNtpPrimary(&_mqtt_prefs)); + } else if (memcmp(config, "mqtt.remote", 11) == 0) { + sprintf(reply, "> %s", _mqtt_prefs.mqtt_remote_enabled ? "on" : "off"); + } else if (memcmp(config, "mqtt.useacl", 11) == 0) { + sprintf(reply, "> %s", _mqtt_prefs.mqtt_use_acl ? "on" : "off"); + } else if (memcmp(config, "mqtt.admin", 10) == 0) { + // Admin key is a public key, but which key controls the device is only + // revealed over serial (sender_timestamp == 0), mirroring wifi.pwd/token. + if (_mqtt_prefs.mqtt_admin_public_key[0] == '\0') { + strcpy(reply, "> (not set)"); + } else if (sender_timestamp == 0) { + sprintf(reply, "> %s", _mqtt_prefs.mqtt_admin_public_key); + } else { + strcpy(reply, "> (set, serial only)"); + } } else if (config[0] == 'm' && config[1] == 'q' && config[2] == 't' && config[3] == 't' && config[4] >= '1' && config[4] <= ('0' + MAX_MQTT_SLOTS) && config[5] == '.') { // Slot-based commands: get mqtt1.preset, get mqtt1.server, etc. @@ -869,6 +914,8 @@ bool CommonCLI::handleObserverGetCmd(uint32_t sender_timestamp, const char* conf } else { strcpy(reply, "> (not set - custom slots use username/password auth)"); } + } else if (memcmp(subcmd, "remote", 6) == 0) { + sprintf(reply, "> %s", _mqtt_prefs.mqtt_slot_remote_enabled[slot] ? "on" : "off"); } else if (memcmp(subcmd, "diag", 4) == 0) { MQTTBridge::formatSlotDiagReply(reply, 160, slot); } else { From 4e30bff515470f75fa550f35c8c687aafbbb346e Mon Sep 17 00:00:00 2001 From: agessaman Date: Tue, 21 Jul 2026 10:00:21 -0700 Subject: [PATCH 5/6] feat(mqtt): wire RemoteControl into MQTTBridge (per-slot, kill switch) Binds the RemoteControl policy engine to the slot clients: each slot registers an onMessage callback that claims a single lock-free pending slot; the bridge task verifies + executes it and publishes the signed response to the originating slot. Subscriptions are reconciled live against the global master (mqtt.remote) and per-slot (mqttN.remote) flags, so the kill switch unsubscribes every slot and drops any in-flight command without a WSS restart. The bridge implements the RemoteControl crypto/authorizer/executor/clock seams privately (JWTHelper + LocalIdentity, MQTTPrefs + ACL callback, CLI callback, millis/time). Remote commands run with a non-zero sentinel sender_timestamp so serial-only CLI gates (prv.key, freq, erase) still refuse them. --- src/helpers/bridges/MQTTBridge.cpp | 263 +++++++++++++++++++++++++++++ src/helpers/bridges/MQTTBridge.h | 81 ++++++++- 2 files changed, 343 insertions(+), 1 deletion(-) diff --git a/src/helpers/bridges/MQTTBridge.cpp b/src/helpers/bridges/MQTTBridge.cpp index 4102cbe5c7..0479510744 100644 --- a/src/helpers/bridges/MQTTBridge.cpp +++ b/src/helpers/bridges/MQTTBridge.cpp @@ -10,6 +10,8 @@ #include #include #include +#include +#include #include #include #include @@ -597,7 +599,15 @@ MQTTBridge::MQTTBridge(NodePrefs *prefs, MQTTPrefs *obs, mesh::PacketManager *mg // Seed with the worst-case (max runtime slots) budget; end() recomputes the // slot-scaled timeout before each stop via setStopTimeoutMs(). , _lifecycle_ops(this), _lifecycle(_lifecycle_ops, mqttStopTimeoutForSlots(RUNTIME_MQTT_SLOTS)) + , _remote_control(this, this, this, this) { + // Remote command channel starts idle; topics are built in begin() once the + // IATA and device id are known. + _command_topic[0] = '\0'; + _response_topic[0] = '\0'; + _pending_command.length = 0; + _pending_command.origin_slot = -1; + // Initialize default values strncpy(_origin, "MeshCore-Repeater", sizeof(_origin) - 1); strncpy(_iata, "XXX", sizeof(_iata) - 1); @@ -821,6 +831,11 @@ void MQTTBridge::begin() { // Check for configuration mismatch: bridge.source=tx but mqtt.tx=off checkConfigurationMismatch(); + // Derive the remote command/response topics from the (now finalized) IATA and + // device id. Rebuilt on every begin(), so an IATA change (which restarts the + // bridge) refreshes them. + buildRemoteTopics(); + MQTT_DEBUG_PRINTLN("Config: Origin=%s, IATA=%s, Device=%s", _origin, _iata, _device_id); // Apply slot presets from preferences @@ -1372,6 +1387,13 @@ void MQTTBridge::mqttTaskLoop() { // Maintain slot connections (token renewal, reconnect with backoff) maintainSlotConnections(); + // Bring remote-command subscriptions in line with the enable flags (global + // master + per-slot), then run any command queued by a slot callback. The + // kill switch (mqtt.remote off) takes effect here: every slot unsubscribes + // and a pending command is dropped without executing. + reconcileRemoteSubscriptions(); + processPendingRemoteCommand(); + // Process packet queue processPacketQueue(); @@ -1558,8 +1580,19 @@ void MQTTBridge::initSlotClients() { } _slots[index].connected = false; _slots[index].connected_at_ms = 0; // stability clock only runs while connected + // The MQTT session dropped: any command-topic subscription is gone. Clear + // the flag so reconcileRemoteSubscriptions() re-subscribes after reconnect. + _slots[index].remote_subscribed = false; updateCachedConnectionStatus(); }); + // Inbound remote commands. Runs on this client's esp-mqtt event task; it only + // claims and copies the payload, then hands off to the bridge task (Core 0) + // via processPendingRemoteCommand() so JWT verification never runs here. + slot.client->onMessage([this, index](char* topic, char* payload, int retain, int qos, bool dup) { + // The library null-terminates payload; JWT tokens carry no NULs, so strlen + // is the true length. + enqueueRemoteCommand(index, topic, payload); + }); slot.client->onError([this, index](esp_mqtt_error_codes error) { _slots[index].last_tls_err = error.esp_tls_last_esp_err; _slots[index].last_tls_stack_err = error.esp_tls_stack_err; @@ -4042,4 +4075,234 @@ void MQTTBridge::setStatsSources(mesh::Dispatcher* dispatcher, mesh::Radio* radi _ms = ms; } +// =========================================================================== +// Remote command execution (JWT-authenticated over MQTT). +// Policy lives in RemoteControl (host-tested); the methods below bind it to the +// slots, MQTTPrefs, the ACL/CLI callbacks and the device identity. +// =========================================================================== + +static_assert(RC_PUB_KEY_SIZE == PUB_KEY_SIZE, + "RemoteControl key size must match mesh PUB_KEY_SIZE"); + +void MQTTBridge::buildRemoteTopics() { + // The topic namespace embeds the IATA region code, so remote control requires + // a real one. With none set the topics stay empty and reconcile never + // subscribes (remote control is effectively unavailable until IATA is set). + if (!isIATAValid()) { + _command_topic[0] = '\0'; + _response_topic[0] = '\0'; + return; + } + snprintf(_command_topic, sizeof(_command_topic), "meshcore/%s/%s/serial/commands", _iata, _device_id); + snprintf(_response_topic, sizeof(_response_topic), "meshcore/%s/%s/serial/responses", _iata, _device_id); +} + +void MQTTBridge::reconcileRemoteSubscriptions() { + const bool master = _obs && _obs->mqtt_remote_enabled; + const bool topic_ok = _command_topic[0] != '\0'; + for (int i = 0; i < RUNTIME_MQTT_SLOTS; i++) { + MQTTSlot& slot = _slots[i]; + const bool desired = master && topic_ok && slot.connected && slot.client && + _obs->mqtt_slot_remote_enabled[i]; + if (desired && !slot.remote_subscribed) { + slot.client->subscribe(_command_topic, 1); + slot.remote_subscribed = true; + MQTT_DEBUG_PRINTLN("MQTT%d subscribed to remote commands", i + 1); + } else if (!desired && slot.remote_subscribed) { + if (slot.connected && slot.client) slot.client->unsubscribe(_command_topic); + slot.remote_subscribed = false; + MQTT_DEBUG_PRINTLN("MQTT%d unsubscribed from remote commands", i + 1); + } + } +} + +void MQTTBridge::enqueueRemoteCommand(int slot_index, const char* topic, const char* payload) { + // Runs on an esp-mqtt event task. Keep it to cheap checks + a copy; the JWT + // work happens on the bridge task in processPendingRemoteCommand(). + if (!_obs || !_obs->mqtt_remote_enabled) return; + if (slot_index < 0 || slot_index >= RUNTIME_MQTT_SLOTS) return; + if (!_obs->mqtt_slot_remote_enabled[slot_index]) return; + if (!topic || _command_topic[0] == '\0' || strcmp(topic, _command_topic) != 0) return; + if (!payload) return; + const size_t len = strlen(payload); + if (len == 0 || len >= sizeof(_pending_command.payload)) return; + + // The same command can arrive on more than one connected slot. Claim the + // single in-flight buffer; the first claim wins and duplicates are dropped + // until the bridge task finishes (RemoteControl's nonce tracker also rejects + // any that slip through as replays). + bool expected = false; + if (!_cmd_busy.compare_exchange_strong(expected, true)) return; + memcpy(_pending_command.payload, payload, len); + _pending_command.payload[len] = '\0'; + _pending_command.length = (unsigned int)len; + _pending_command.origin_slot = slot_index; + _cmd_ready.store(true, std::memory_order_release); +} + +void MQTTBridge::processPendingRemoteCommand() { + if (!_cmd_ready.load(std::memory_order_acquire)) return; + + const int slot = _pending_command.origin_slot; + // Kill switch: if remote control (or this slot) was turned off after the + // command was queued, drop it without executing. + const bool still_enabled = _obs && _obs->mqtt_remote_enabled && + slot >= 0 && slot < RUNTIME_MQTT_SLOTS && + _obs->mqtt_slot_remote_enabled[slot]; + if (still_enabled && _command_executor) { + const size_t kOutSize = 2048; // header + payload(base64) + 128-char hex sig + char* out = (char*)malloc(kOutSize); + if (out) { + const RemoteControl::Outcome oc = + _remote_control.process(_pending_command.payload, _device_id, out, kOutSize); + if (oc == RemoteControl::Outcome::ResponseReady && _response_topic[0] != '\0') { + publishToSlot(slot, _response_topic, out, false, 1); + } + free(out); + } + } + // Free the buffer for the next command. + _cmd_ready.store(false, std::memory_order_release); + _cmd_busy.store(false, std::memory_order_release); +} + +// --- RemoteControl seams --------------------------------------------------- + +bool MQTTBridge::parseRequest(const char* token, RemoteCommandRequest& out) { + if (!token) return false; + const char* dot1 = strchr(token, '.'); + if (!dot1) return false; + const char* dot2 = strchr(dot1 + 1, '.'); + if (!dot2) return false; + const size_t payload_b64_len = dot2 - (dot1 + 1); + + char* payload_b64 = (char*)malloc(payload_b64_len + 1); + if (!payload_b64) return false; + memcpy(payload_b64, dot1 + 1, payload_b64_len); + payload_b64[payload_b64_len] = '\0'; + + char* json = (char*)malloc(512); + if (!json) { free(payload_b64); return false; } + const size_t json_len = JWTHelper::base64UrlDecode(payload_b64, (uint8_t*)json, 512); + free(payload_b64); + if (json_len == 0) { free(json); return false; } + json[json_len] = '\0'; + + DynamicJsonDocument doc(512); + const DeserializationError err = deserializeJson(doc, json); + free(json); + if (err) return false; + + StrHelper::strncpy(out.command, doc["command"] | "", sizeof(out.command)); + StrHelper::strncpy(out.target, doc["target"] | "", sizeof(out.target)); + StrHelper::strncpy(out.nonce, doc["nonce"] | "", sizeof(out.nonce)); + StrHelper::strncpy(out.public_key, doc["publicKey"] | "", sizeof(out.public_key)); + return true; +} + +bool MQTTBridge::verifySignature(const char* token, char* out_pubkey_hex, size_t out_size) { + return JWTHelper::verifyToken(token, nullptr, 0, out_pubkey_hex, out_size, + nullptr, 0, nullptr, nullptr); +} + +bool MQTTBridge::signResponse(const RemoteCommandResponse& resp, char* out_jwt, size_t out_size) { + if (!_identity || !out_jwt || out_size == 0) return false; + + // Header: {"alg":"Ed25519","typ":"JWT"} + char header_b64[96]; + DynamicJsonDocument header_doc(64); + header_doc["alg"] = "Ed25519"; + header_doc["typ"] = "JWT"; + char header_json[64]; + const size_t header_json_len = serializeJson(header_doc, header_json, sizeof(header_json)); + if (header_json_len == 0) return false; + const size_t header_len = JWTHelper::base64UrlEncode((uint8_t*)header_json, header_json_len, + header_b64, sizeof(header_b64)); + if (header_len == 0) return false; + header_b64[header_len] = '\0'; + + // Payload. Field order/names match what the letsmesh decoder expects. + DynamicJsonDocument payload_doc(1024); + payload_doc["publicKey"] = resp.device_id ? resp.device_id : ""; + if (resp.command && resp.command[0] != '\0') payload_doc["command"] = resp.command; + payload_doc["request_id"] = resp.request_id ? resp.request_id : ""; + payload_doc["success"] = resp.success; + payload_doc["response"] = resp.response ? resp.response : ""; + payload_doc["iat"] = resp.iat; + payload_doc["exp"] = resp.exp; + + char* payload_json = (char*)malloc(1024); + if (!payload_json) return false; + const size_t payload_json_len = serializeJson(payload_doc, payload_json, 1024); + if (payload_json_len == 0 || payload_json_len >= 1024) { free(payload_json); return false; } + + char* payload_b64 = (char*)malloc(1400); + if (!payload_b64) { free(payload_json); return false; } + const size_t payload_len = JWTHelper::base64UrlEncode((uint8_t*)payload_json, payload_json_len, + payload_b64, 1400); + free(payload_json); + if (payload_len == 0) { free(payload_b64); return false; } + payload_b64[payload_len] = '\0'; + + // Signing input = base64url(header) + "." + base64url(payload). + const size_t signing_len = header_len + 1 + payload_len; + char* signing_input = (char*)malloc(signing_len + 1); + if (!signing_input) { free(payload_b64); return false; } + memcpy(signing_input, header_b64, header_len); + signing_input[header_len] = '.'; + memcpy(signing_input + header_len + 1, payload_b64, payload_len); + signing_input[signing_len] = '\0'; + + uint8_t signature[64]; + _identity->sign(signature, (const uint8_t*)signing_input, (int)signing_len); + free(signing_input); + + // Hex-encode the signature (matches the incoming command format). + char sig_hex[129]; + for (int i = 0; i < 64; i++) sprintf(sig_hex + (i * 2), "%02X", signature[i]); + sig_hex[128] = '\0'; + + const int written = snprintf(out_jwt, out_size, "%s.%s.%s", header_b64, payload_b64, sig_hex); + free(payload_b64); + return written > 0 && (size_t)written < out_size; +} + +bool MQTTBridge::useACL() { + return _obs && _obs->mqtt_use_acl; +} + +bool MQTTBridge::authorize(const uint8_t* pubkey, size_t len) { + if (!pubkey || len != PUB_KEY_SIZE || !_obs) return false; + if (_obs->mqtt_use_acl) { + return _acl_callbacks && _acl_callbacks->isPublicKeyAdmin(pubkey, len); + } + // ACL disabled: match against the explicit admin key. + if (_obs->mqtt_admin_public_key[0] != '\0') { + uint8_t admin[PUB_KEY_SIZE]; + if (mesh::Utils::fromHex(admin, PUB_KEY_SIZE, _obs->mqtt_admin_public_key)) { + return memcmp(pubkey, admin, PUB_KEY_SIZE) == 0; + } + } + return false; +} + +void MQTTBridge::execute(const char* command, char* reply, size_t reply_size) { + if (reply_size == 0) return; + reply[0] = '\0'; + if (!_command_executor) { + StrHelper::strncpy(reply, "Command executor not available", reply_size); + return; + } + // REMOTE_COMMAND_SENDER_TS is non-zero, so serial-only CLI gates reject the + // command (a remote admin gets mesh-admin access, never console-only access). + _command_executor->handleCommand(REMOTE_COMMAND_SENDER_TS, command, reply); +} + +unsigned long MQTTBridge::millisNow() { return millis(); } + +unsigned long MQTTBridge::unixNow() { + const time_t now = time(nullptr); + return (now > 0) ? (unsigned long)now : 0; +} + #endif diff --git a/src/helpers/bridges/MQTTBridge.h b/src/helpers/bridges/MQTTBridge.h index dbf0a712ce..7fcafdc018 100644 --- a/src/helpers/bridges/MQTTBridge.h +++ b/src/helpers/bridges/MQTTBridge.h @@ -10,6 +10,7 @@ #include "helpers/JWTHelper.h" #include "helpers/MQTTPresets.h" #include "helpers/MQTTLifecycle.h" +#include "helpers/RemoteControl.h" #include #ifdef WITH_SNMP @@ -65,7 +66,30 @@ class MeshSNMPAgent; // Forward declaration * - Configure slots via: set mqtt1.preset , set mqtt2.preset , etc. * - Available presets: analyzer-us, analyzer-eu, meshmapper, custom, none */ -class MQTTBridge : public BridgeBase { + +// Callbacks a MeshCore variant supplies so JWT-authenticated remote commands can +// be authorized against its ACL admin list and executed through its CLI. Kept +// minimal; the mqtt.useacl flag and admin key live in MQTTPrefs, not here. +class MQTTBridgeACLCallbacks { +public: + virtual ~MQTTBridgeACLCallbacks() {} + virtual bool isPublicKeyAdmin(const uint8_t* pubkey, size_t key_len) = 0; +}; + +class MQTTBridgeCommandExecutor { +public: + virtual ~MQTTBridgeCommandExecutor() {} + virtual void handleCommand(uint32_t sender_timestamp, const char* command, char* reply) = 0; +}; + +// The bridge implements the RemoteControl seams privately: it adapts JWTHelper + +// LocalIdentity (crypto), MQTTPrefs + the ACL callback (authorization), the CLI +// callback (execution) and millis()/time() (clock) for the policy engine. +class MQTTBridge : public BridgeBase, + private RemoteControlCrypto, + private RemoteControlAuthorizer, + private RemoteControlExecutor, + private RemoteControlClock { public: // Max NTP servers in a try-list: 1 custom primary + the built-in fallbacks. static const int kMaxNtpServers = 6; @@ -118,6 +142,12 @@ class MQTTBridge : public BridgeBase { // disconnect-after-connect. first_disconnect_time is intentionally separate // so the existing 'mqttN.diag' "first_disc" semantics don't change. unsigned long current_outage_started_ms; + + // Remote command channel: true once this slot has an active subscription to + // the command topic. Reset on disconnect; reconciled by the bridge task + // against the global + per-slot enable flags. (The desired state is read + // live from MQTTPrefs, so only this actual-state bit needs to persist here.) + bool remote_subscribed; }; MQTTSlot _slots[RUNTIME_MQTT_SLOTS]; @@ -461,6 +491,48 @@ class MQTTBridge : public BridgeBase { // _prefs (held by BridgeBase) still provides upstream fields (freq/sf/node_name…). MQTTPrefs* _obs = nullptr; + // --- Remote command execution (JWT-authenticated over MQTT) ---------------- + // Remote commands sent by the CLI executor use a non-zero sentinel timestamp so + // the serial-only CLI gates (prv.key, freq, erase, …) treat them as NOT local + // serial and refuse — a remote admin gets mesh-admin-equivalent access, never + // console-only access. Small enough that clock-sync's `ts > now` never trips. + static const uint32_t REMOTE_COMMAND_SENDER_TS = 1; + + MQTTBridgeACLCallbacks* _acl_callbacks = nullptr; + MQTTBridgeCommandExecutor* _command_executor = nullptr; + char _command_topic[128]; // meshcore/{IATA}/{DEVICE}/serial/commands + char _response_topic[128]; // meshcore/{IATA}/{DEVICE}/serial/responses + + // One in-flight command. The esp-mqtt callback (any slot's event task) claims + // _cmd_busy, copies the payload, then publishes _cmd_ready; the bridge task + // (Core 0) consumes it. Lock-free: _cmd_busy gates the single-slot buffer and + // _cmd_ready hands it off with release/acquire ordering. + struct PendingCommand { + char payload[768]; + unsigned int length; + int origin_slot; + }; + PendingCommand _pending_command; + std::atomic _cmd_busy{false}; + std::atomic _cmd_ready{false}; + + RemoteControl _remote_control; + + void buildRemoteTopics(); // (re)derive command/response topics from IATA + device id + void reconcileRemoteSubscriptions(); // subscribe/unsubscribe slots to match the enable flags + void enqueueRemoteCommand(int slot_index, const char* topic, const char* payload); + void processPendingRemoteCommand(); // run the policy engine + publish the response (bridge task) + + // RemoteControl seams (see RemoteControl.h). Firmware-only implementations. + bool parseRequest(const char* token, RemoteCommandRequest& out) override; + bool verifySignature(const char* token, char* out_pubkey_hex, size_t out_size) override; + bool signResponse(const RemoteCommandResponse& resp, char* out_jwt, size_t out_size) override; + bool useACL() override; + bool authorize(const uint8_t* pubkey, size_t len) override; + void execute(const char* command, char* reply, size_t reply_size) override; + unsigned long millisNow() override; + unsigned long unixNow() override; + public: MQTTBridge(NodePrefs *prefs, MQTTPrefs *obs, mesh::PacketManager *mgr, mesh::RTCClock *rtc, mesh::LocalIdentity *identity); @@ -604,6 +676,13 @@ class MQTTBridge : public BridgeBase { void setStatsSources(mesh::Dispatcher* dispatcher, mesh::Radio* radio, mesh::MainBoard* board, mesh::MillisecondClock* ms); + /** Supply the ACL admin-list lookup used to authorize remote commands. Pass + * null on variants without an ACL (remote commands then fall back to the + * explicit mqtt.admin key). */ + void setACLCallbacks(MQTTBridgeACLCallbacks* callbacks) { _acl_callbacks = callbacks; } + /** Supply the CLI executor used to run authorized remote commands. */ + void setCommandExecutor(MQTTBridgeCommandExecutor* executor) { _command_executor = executor; } + #ifdef WITH_SNMP void setSNMPAgent(MeshSNMPAgent* agent) { _snmp_agent = agent; } #endif From 1d839e44790172b4803f34be2a77dad5fd540e1b Mon Sep 17 00:00:00 2001 From: agessaman Date: Tue, 21 Jul 2026 10:00:21 -0700 Subject: [PATCH 6/6] feat(examples): wire remote-command ACL + CLI callbacks Add a shared MQTTRemoteCallbacks.h (ACL admin authorizer + CLI executor adapters) and register them on the bridge in the repeater and room-server examples right after construction, at every bridge-creation site. --- examples/simple_repeater/MyMesh.cpp | 1 + examples/simple_repeater/MyMesh.h | 17 +++++++++ examples/simple_room_server/MyMesh.cpp | 1 + examples/simple_room_server/MyMesh.h | 17 +++++++++ src/helpers/bridges/MQTTRemoteCallbacks.h | 44 +++++++++++++++++++++++ 5 files changed, 80 insertions(+) create mode 100644 src/helpers/bridges/MQTTRemoteCallbacks.h diff --git a/examples/simple_repeater/MyMesh.cpp b/examples/simple_repeater/MyMesh.cpp index 2358581c61..5d84ed890f 100644 --- a/examples/simple_repeater/MyMesh.cpp +++ b/examples/simple_repeater/MyMesh.cpp @@ -1106,6 +1106,7 @@ void MyMesh::begin(FILESYSTEM *fs) { #ifdef WITH_MQTT_BRIDGE // Defer construction to avoid static init crashes on ESP32 classic bridge = new MQTTBridge(&_prefs, _cli.getObserverPrefs(), _mgr, getRTCClock(), &self_id); + wireBridgeRemoteControl(); #endif if (bridge) { // Set device public key for MQTT topics diff --git a/examples/simple_repeater/MyMesh.h b/examples/simple_repeater/MyMesh.h index 562ba93b4e..329dbce1dd 100644 --- a/examples/simple_repeater/MyMesh.h +++ b/examples/simple_repeater/MyMesh.h @@ -26,6 +26,7 @@ #ifdef WITH_MQTT_BRIDGE #include "helpers/bridges/MQTTBridge.h" +#include "helpers/bridges/MQTTRemoteCallbacks.h" #define WITH_BRIDGE #include "helpers/esp32/WebConfigServer.h" // defines WITH_WEBCONFIG on ESP32 #endif @@ -133,6 +134,8 @@ class MyMesh : public mesh::Mesh, public CommonCLICallbacks ESPNowBridge bridge; #elif defined(WITH_MQTT_BRIDGE) MQTTBridge* bridge; + ACLAdminAuthCallbacks _acl_callbacks; + CLICommandExecutor _command_executor; #endif #ifdef WITH_SNMP MeshSNMPAgent _snmp_agent; @@ -313,6 +316,7 @@ class MyMesh : public mesh::Mesh, public CommonCLICallbacks if (!bridge) { #ifdef WITH_MQTT_BRIDGE bridge = new MQTTBridge(&_prefs, _cli.getObserverPrefs(), _mgr, getRTCClock(), &self_id); + wireBridgeRemoteControl(); #endif if (!bridge) return; } @@ -344,6 +348,19 @@ class MyMesh : public mesh::Mesh, public CommonCLICallbacks } } +#ifdef WITH_MQTT_BRIDGE + // Point the bridge's remote-command hooks at this variant's ACL and CLI. The + // bridge keeps these pointers across begin()/end(), so calling it once after + // construction is enough. + void wireBridgeRemoteControl() { + if (!bridge) return; + _acl_callbacks = ACLAdminAuthCallbacks(&acl); + _command_executor = CLICommandExecutor(&_cli); + bridge->setACLCallbacks(&_acl_callbacks); + bridge->setCommandExecutor(&_command_executor); + } +#endif + void restartBridge() override { if (!bridge || !bridge->isRunning()) return; #ifdef WITH_WEBCONFIG diff --git a/examples/simple_room_server/MyMesh.cpp b/examples/simple_room_server/MyMesh.cpp index 1fc1ef88a5..54d49799b3 100644 --- a/examples/simple_room_server/MyMesh.cpp +++ b/examples/simple_room_server/MyMesh.cpp @@ -913,6 +913,7 @@ void MyMesh::begin(FILESYSTEM *fs) { if (_prefs.bridge_enabled) { // Defer construction to avoid static init crashes on ESP32 classic bridge = new MQTTBridge(&_prefs, _cli.getObserverPrefs(), _mgr, getRTCClock(), &self_id); + wireBridgeRemoteControl(); if (bridge) { // Set device public key for MQTT topics char device_id[65]; diff --git a/examples/simple_room_server/MyMesh.h b/examples/simple_room_server/MyMesh.h index 7478204144..b16c4c9f42 100644 --- a/examples/simple_room_server/MyMesh.h +++ b/examples/simple_room_server/MyMesh.h @@ -27,6 +27,7 @@ #ifdef WITH_MQTT_BRIDGE #include "helpers/bridges/MQTTBridge.h" +#include "helpers/bridges/MQTTRemoteCallbacks.h" #define WITH_BRIDGE #include "helpers/esp32/WebConfigServer.h" // defines WITH_WEBCONFIG on ESP32 #endif @@ -174,6 +175,8 @@ class MyMesh : public mesh::Mesh, public CommonCLICallbacks #endif #ifdef WITH_MQTT_BRIDGE MQTTBridge* bridge; + ACLAdminAuthCallbacks _acl_callbacks; + CLICommandExecutor _command_executor; #endif #ifdef WITH_MQTT_BRIDGE AlertReporter _alerter; @@ -308,6 +311,7 @@ class MyMesh : public mesh::Mesh, public CommonCLICallbacks if (!bridge) { #ifdef WITH_MQTT_BRIDGE bridge = new MQTTBridge(&_prefs, _cli.getObserverPrefs(), _mgr, getRTCClock(), &self_id); + wireBridgeRemoteControl(); #endif if (!bridge) return; } @@ -338,6 +342,19 @@ class MyMesh : public mesh::Mesh, public CommonCLICallbacks } } +#ifdef WITH_MQTT_BRIDGE + // Point the bridge's remote-command hooks at this variant's ACL and CLI. The + // bridge keeps these pointers across begin()/end(), so calling it once after + // construction is enough. + void wireBridgeRemoteControl() { + if (!bridge) return; + _acl_callbacks = ACLAdminAuthCallbacks(&acl); + _command_executor = CLICommandExecutor(&_cli); + bridge->setACLCallbacks(&_acl_callbacks); + bridge->setCommandExecutor(&_command_executor); + } +#endif + void restartBridge() override { if (!bridge || !bridge->isRunning()) return; #ifdef WITH_WEBCONFIG diff --git a/src/helpers/bridges/MQTTRemoteCallbacks.h b/src/helpers/bridges/MQTTRemoteCallbacks.h new file mode 100644 index 0000000000..9a3c64e08a --- /dev/null +++ b/src/helpers/bridges/MQTTRemoteCallbacks.h @@ -0,0 +1,44 @@ +#pragma once + +// Adapters that bind a MeshCore variant's ACL admin list and CLI to the +// MQTTBridge remote-command hooks. Shared by the repeater and room-server +// examples so the wiring stays identical between them. + +#ifdef WITH_MQTT_BRIDGE + +#include "helpers/bridges/MQTTBridge.h" +#include "helpers/ClientACL.h" +#include "helpers/CommonCLI.h" +#include + +// Authorizes a remote command's signing key against the variant's ACL: the key +// must belong to a known client flagged as admin. +class ACLAdminAuthCallbacks : public MQTTBridgeACLCallbacks { + ClientACL* _acl; +public: + explicit ACLAdminAuthCallbacks(ClientACL* acl = nullptr) : _acl(acl) {} + bool isPublicKeyAdmin(const uint8_t* pubkey, size_t key_len) override { + if (!_acl) return false; + ClientInfo* client = _acl->getClient(pubkey, (int)key_len); + return client != nullptr && client->isAdmin(); + } +}; + +// Runs an authorized remote command through the variant's CLI. CommonCLI mutates +// its command buffer, so the const command is copied into a local first. +class CLICommandExecutor : public MQTTBridgeCommandExecutor { + CommonCLI* _cli; +public: + explicit CLICommandExecutor(CommonCLI* cli = nullptr) : _cli(cli) {} + void handleCommand(uint32_t sender_timestamp, const char* command, char* reply) override { + if (!_cli) return; + char buf[256]; + size_t n = strlen(command); + if (n >= sizeof(buf)) n = sizeof(buf) - 1; + memcpy(buf, command, n); + buf[n] = '\0'; + _cli->handleCommand(sender_timestamp, buf, reply); + } +}; + +#endif // WITH_MQTT_BRIDGE