Share the saved admin password across ESP32 management services
Use the committed node credential for setup WiFi, WebConfig and browser OTA. Add persistent ESP32 WiFi Companion management credentials without changing preferences layouts, revoke stale browser sessions on rotation, retain scoped save receipts, and coordinate credential changes with flash uploads. Keep external-service credentials and trusted TCP protocol compatibility independent. Update live join QR codes, failure handling and documentation. Validated 51 native cases, 158 related host and browser methods, and serial Full V4 Companion, Station G3 Repeater, ESP32-C6 Room Server and V4 Sensor firmware builds.
This commit is contained in:
1 parent
a6a3cb5ebc
commit
cd02e55824
58 files changed
+5749
-2439
No files matched your search
+74
-4
@@ -1,4 +1,6 @@
|
||||
#include <atomic>
|
||||
#define MESH_MANAGEMENT_PASSWORD_HOST_TEST 1
|
||||
#include <helpers/esp32/ManagementPassword.h>
|
||||
#include <cassert>
|
||||
#include <cstdlib>
|
||||
#include <cstring>
|
||||
@@ -38,13 +40,15 @@ int xTaskCreate(Task task, const char*, unsigned, void*, unsigned, void*) {
|
||||
// algorithm; both vectors are fixed independently of the uploader callbacks.
|
||||
const char fixture_md5[]="7dea362b3fac8e00956a4952a3d4f474";
|
||||
const char fixture_corrupt_md5[]="33cdeccccebe80329f1fdbee7f5874cb";
|
||||
struct Parameter { String text=fixture_md5; String value() { return text; } };
|
||||
struct Parameter { String text=fixture_md5; const String& value() { return text; } };
|
||||
struct Response {
|
||||
int code;
|
||||
String body;
|
||||
void addHeader(const char*, const char*) {}
|
||||
std::vector<std::pair<String, String>> headers;
|
||||
void addHeader(const char* key, const char* value) { headers.emplace_back(key, value); }
|
||||
};
|
||||
unsigned fixture_now=0;
|
||||
unsigned millis() { return fixture_now; }
|
||||
struct Client {
|
||||
unsigned _rx_timeout=0, _rx_last_packet=fixture_now;
|
||||
bool closed=false;
|
||||
@@ -70,6 +74,10 @@ struct Request {
|
||||
Response* queued_response=nullptr;
|
||||
std::function<void()> disconnected;
|
||||
Parameter md5;
|
||||
Parameter authorization;
|
||||
bool authorization_present = false;
|
||||
bool hasHeader(const char*) { return authorization_present; }
|
||||
Parameter* getHeader(const char*) { return &authorization; }
|
||||
Client connection;
|
||||
Request() { accept(&connection); }
|
||||
~Request() { free(_tempObject); delete queued_response; }
|
||||
@@ -92,9 +100,12 @@ using AsyncWebServerRequest=Request;
|
||||
static AsyncWebServerRequest* ota_upload_owner=nullptr;
|
||||
using AsyncWebServerResponse=Response;
|
||||
struct AsyncWebServer {
|
||||
std::vector<std::function<void(Request*)>> gets;
|
||||
std::function<void(Request*)> completion;
|
||||
std::function<void(Request*,String,size_t,uint8_t*,size_t,bool)> upload;
|
||||
template<class F> void on(const char*, int, F) {}
|
||||
template<class F> void on(const char*, int method, F callback) {
|
||||
assert(method == HTTP_GET); gets.push_back(callback);
|
||||
}
|
||||
template<class F,class U> void on(const char*, int method, F callback, U chunks) {
|
||||
assert(method==HTTP_POST); completion=callback; upload=chunks;
|
||||
}
|
||||
@@ -154,12 +165,14 @@ struct UpdateFake {
|
||||
} Update;
|
||||
struct AsyncElegantOtaClass {
|
||||
AsyncWebServer* _server=nullptr;
|
||||
bool _authRequired=false;
|
||||
bool _authRequired=false, _managementPassword=false;
|
||||
bool authenticateRequest(AsyncWebServerRequest*, uint32_t* generation=nullptr);
|
||||
String _username, _password, _id="fixture";
|
||||
void begin(AsyncWebServer*, const char*, const char*);
|
||||
void restart();
|
||||
bool setEnabled(bool);
|
||||
};
|
||||
@AUTH@
|
||||
@BEGIN@
|
||||
@RESTART@
|
||||
|
||||
@@ -178,6 +191,63 @@ int main() {
|
||||
server.upload(&request,"firmware",0,data,sizeof(data),true);
|
||||
in_network_callback=false;
|
||||
};
|
||||
#ifdef TEST_OTA_MANAGEMENT_PASSWORD
|
||||
{
|
||||
using namespace mesh::esp32::managementPassword;
|
||||
ota._managementPassword = true;
|
||||
Request unpublished;
|
||||
server.gets[0](&unpublished); assert(unpublished.code == 503 && Update.begins == 0);
|
||||
assert(publish("password"));
|
||||
auto authorize = [](Request& request, const char* value = "Basic YWRtaW46cGFzc3dvcmQ=") {
|
||||
request.authorization_present = true; request.authorization.text = value;
|
||||
};
|
||||
assert(server.gets.size() == 2);
|
||||
for (auto& get : server.gets) {
|
||||
Request absent; get(&absent); assert(absent.code == 401);
|
||||
assert(absent.queued_response->headers.size() == 2);
|
||||
assert(absent.queued_response->headers[0].first == "WWW-Authenticate"
|
||||
&& absent.queued_response->headers[0].second == "Basic realm=\"MeshCore admin\", charset=\"UTF-8\"");
|
||||
assert(absent.queued_response->headers[1].first == "Cache-Control"
|
||||
&& absent.queued_response->headers[1].second == "no-store");
|
||||
Request wrong; authorize(wrong, "Basic wrong"); get(&wrong); assert(wrong.code == 401);
|
||||
Request good; authorize(good); get(&good); assert(good.code == 200);
|
||||
}
|
||||
Request wrong_post; authorize(wrong_post, "Basic wrong");
|
||||
upload(wrong_post); complete(wrong_post);
|
||||
assert(wrong_post.code == 401 && Update.begins == 0 && Update.writes == 0 && !uploadActive());
|
||||
assert(publish("different"));
|
||||
Request stale; authorize(stale); upload(stale); assert(stale.code == 401 && Update.begins == 0);
|
||||
const char* updated = "Basic YWRtaW46ZGlmZmVyZW50";
|
||||
{
|
||||
ChangeGuard saving; assert(saving.acquired());
|
||||
Request blocked; authorize(blocked, updated); upload(blocked);
|
||||
assert(blocked.code == 409 && Update.begins == 0 && !uploadActive());
|
||||
}
|
||||
uint8_t half[4] = {};
|
||||
Request partial; authorize(partial, updated);
|
||||
server.upload(&partial, "firmware", 0, half, sizeof(half), false);
|
||||
assert(Update.begins == 1 && Update.writes == 1 && uploadActive());
|
||||
{
|
||||
ChangeGuard changing; assert(!changing.acquired());
|
||||
assert(!publish("not-committed"));
|
||||
}
|
||||
// Authentication is rechecked on every chunk before touching Update.
|
||||
authorize(partial, "Basic wrong");
|
||||
server.upload(&partial, "firmware", sizeof(half), half, sizeof(half), true);
|
||||
assert(partial.code == 401 && Update.writes == 1 && Update.ends == 0);
|
||||
in_network_callback = true; partial.disconnected(); in_network_callback = false;
|
||||
assert(!uploadActive() && !ota_upload_busy.load() && Update.aborts == 1 && !pending_task);
|
||||
Request valid; authorize(valid, updated); upload(valid);
|
||||
assert(uploadActive() && Update.writes == 2);
|
||||
complete(valid);
|
||||
assert(valid.code == 200 && Update.boot_commits == 1 && uploadActive());
|
||||
in_network_callback = true; valid.disconnected(); in_network_callback = false;
|
||||
assert(!uploadActive() && pending_task);
|
||||
auto task = pending_task; pending_task = nullptr; task(nullptr);
|
||||
assert(ESP.restarts == 1);
|
||||
return 0;
|
||||
}
|
||||
#endif
|
||||
#if defined(TEST_OTA_SECOND_FILE) || defined(TEST_OTA_INCOMPLETE_POST)
|
||||
{
|
||||
Request rejected;
|
||||
|
||||
Vendored
+32
-15
@@ -8,6 +8,12 @@
|
||||
#include <cstring>
|
||||
#include <initializer_list>
|
||||
#include <string>
|
||||
#define MESH_MANAGEMENT_PASSWORD_HOST_TEST
|
||||
#include <helpers/esp32/ManagementPassword.h>
|
||||
namespace password = mesh::esp32::managementPassword;
|
||||
#ifndef TEST_ADMIN_PASSWORD
|
||||
#define TEST_ADMIN_PASSWORD ""
|
||||
#endif
|
||||
|
||||
using esp_err_t = int;
|
||||
using wifi_mode_t = int;
|
||||
@@ -46,13 +52,14 @@ struct IPAddress {
|
||||
struct wifi_config_t {
|
||||
struct {
|
||||
uint8_t ssid[32] = {};
|
||||
uint8_t password[64] = {};
|
||||
uint8_t ssid_len = 0, ssid_hidden = 0, channel = 1;
|
||||
wifi_auth_mode_t authmode = WIFI_AUTH_OPEN;
|
||||
} ap;
|
||||
};
|
||||
enum class Fault {
|
||||
None, StaleSsid, HiddenSsid, WrongChannel, WrongProtocol, MissingMode,
|
||||
NotStarted, ZeroIp, DelayedStart, StaleOnce, SetterFailure, WrongAuth, StillConnected,
|
||||
NotStarted, ZeroIp, DelayedStart, StaleOnce, SetterFailure, WrongAuth, WrongPassword, StillConnected,
|
||||
ModeSetterFailure, IpConfigSetterFailure, StaProtocolSetterFailure,
|
||||
SdkModeReadFailure, SdkConfigReadFailure, SdkProtocolReadFailure
|
||||
};
|
||||
@@ -60,12 +67,8 @@ enum class ScanBehavior {
|
||||
Complete, SlowComplete, Failure, Stuck, StopDelayed, Unstoppable,
|
||||
FacadeFailedActive, FacadeFailedStopRejected
|
||||
};
|
||||
constexpr wifi_auth_mode_t expectedAuth() {
|
||||
#ifdef WEBCONFIG_AP_PASSWORD
|
||||
return WEBCONFIG_AP_PASSWORD[0] ? WIFI_AUTH_WPA2_PSK : WIFI_AUTH_OPEN;
|
||||
#else
|
||||
return WIFI_AUTH_OPEN;
|
||||
#endif
|
||||
wifi_auth_mode_t expectedAuth() {
|
||||
return password::apPasswordEligible(TEST_ADMIN_PASSWORD) ? WIFI_AUTH_WPA2_PSK : WIFI_AUTH_OPEN;
|
||||
}
|
||||
void delay(unsigned amount);
|
||||
struct WiFiFake {
|
||||
@@ -122,11 +125,9 @@ struct WiFiFake {
|
||||
return true;
|
||||
}
|
||||
bool softAP(const char* ssid, const char* password, int channel) {
|
||||
#ifdef WEBCONFIG_AP_PASSWORD
|
||||
assert(password != nullptr && strcmp(password, WEBCONFIG_AP_PASSWORD) == 0);
|
||||
#else
|
||||
assert(password == nullptr);
|
||||
#endif
|
||||
if (expectedAuth() == WIFI_AUTH_WPA2_PSK) {
|
||||
assert(password != nullptr && strcmp(password, TEST_ADMIN_PASSWORD) == 0);
|
||||
} else assert(password == nullptr);
|
||||
assert(channel == 6);
|
||||
++ap_calls; polls = 0;
|
||||
if (fault == Fault::SetterFailure) return false;
|
||||
@@ -142,6 +143,8 @@ struct WiFiFake {
|
||||
live_config.ap.authmode = fault == Fault::WrongAuth
|
||||
? (expectedAuth() == WIFI_AUTH_OPEN ? WIFI_AUTH_WPA2_PSK : WIFI_AUTH_OPEN)
|
||||
: expectedAuth();
|
||||
if (password) strcpy(reinterpret_cast<char*>(live_config.ap.password),
|
||||
fault == Fault::WrongPassword ? "obsolete-key" : password);
|
||||
ap_ip = fault == Fault::ZeroIp ? IPAddress() : planned_ip;
|
||||
ap_started = fault != Fault::NotStarted && fault != Fault::DelayedStart;
|
||||
// Every injected state error deliberately returns success here.
|
||||
@@ -279,7 +282,7 @@ struct DNSServer {
|
||||
};
|
||||
unsigned DNSServer::starts = 0;
|
||||
struct NodeSnapshot { char admin_password[16] = {}; };
|
||||
struct Callbacks { void getNodeSnapshot(NodeSnapshot& node) { strcpy(node.admin_password, "configured"); } };
|
||||
struct Callbacks { void getNodeSnapshot(NodeSnapshot& node) { strcpy(node.admin_password, TEST_ADMIN_PASSWORD); } };
|
||||
struct AsyncResponseStream { std::string state; unsigned networks = 0; };
|
||||
struct AsyncWebServerRequest {
|
||||
int status = 0;
|
||||
@@ -327,6 +330,9 @@ struct WebConfigServer {
|
||||
bool _owns_wifi = true, _stopping = false;
|
||||
bool _retry_saved_wifi_in_setup = true, _setup_reconnect_in_progress = true;
|
||||
bool _was_setup_ap = false, _initial_setup = false;
|
||||
bool _setup_provisioning_privilege = false;
|
||||
uint32_t _ap_auth_generation = 0, _ap_rekey_generation = 0;
|
||||
uint32_t _ap_rekey_after = 0, _ap_rekey_deadline = 0;
|
||||
uint32_t _setup_reconnect_deadline = 5, _setup_started_at = 5, _last_activity = 0;
|
||||
const uint8_t _pub_key[2] = {0x62, 0xF2};
|
||||
char _ap_ssid[33] = {}, _wifi_ssid[32] = {};
|
||||
@@ -339,6 +345,7 @@ struct WebConfigServer {
|
||||
void createServer() { ++servers; }
|
||||
void requestNetworkAccess() {}
|
||||
void refreshNetworkPowerHold(uint32_t) {}
|
||||
void initializeManagementPassword();
|
||||
bool startSetupMode(char reply[]);
|
||||
bool checkAuth(AsyncWebServerRequest*) const { return auth_allowed; }
|
||||
void handleScan(AsyncWebServerRequest* req);
|
||||
@@ -361,11 +368,20 @@ void requireEndpoint(const WebConfigServer& portal, const char* reply, bool cach
|
||||
assert(WiFi.scan_result == (cached_scan ? 2 : WIFI_SCAN_FAILED));
|
||||
assert(!WiFi.sdk_scan_active && WiFi.sdk_scan_stops == (stopped_scan ? 1u : 0u));
|
||||
assert(!WiFi.auto_reconnect && !portal._retry_saved_wifi_in_setup);
|
||||
assert(strstr(reply, "join 'MC-62F2' then open http://192.168.4.1/"));
|
||||
assert(strstr(reply, "join 'MC-62F2', http://192.168.4.1/"));
|
||||
assert(strstr(reply, expectedAuth() == WIFI_AUTH_WPA2_PSK
|
||||
? "WiFi: admin password" : TEST_ADMIN_PASSWORD[0]
|
||||
? "open WiFi; admin login required" : "open WiFi; no admin password"));
|
||||
assert(portal._setup_provisioning_privilege == (expectedAuth() == WIFI_AUTH_WPA2_PSK));
|
||||
if (expectedAuth() == WIFI_AUTH_WPA2_PSK)
|
||||
assert(strcmp(reinterpret_cast<const char*>(WiFi.live_config.ap.password), TEST_ADMIN_PASSWORD) == 0);
|
||||
assert(portal.servers == 1);
|
||||
assert(WiFi.initialized && WiFi.driver_stops == 0 && WiFi.driver_deinits == 0);
|
||||
}
|
||||
void reset(Fault fault = Fault::None) {
|
||||
auto& state = password::detail::state();
|
||||
{ password::detail::Lock lock(state); state.current = {};
|
||||
state.changing = state.uploading = state.rotating = false; }
|
||||
WiFi = WiFiFake{}; WiFi.fault = fault;
|
||||
clock_ms = 100;
|
||||
DNSServer::starts = 0; mesh::wireless::control().disabled = false;
|
||||
@@ -383,10 +399,11 @@ int main() {
|
||||
}
|
||||
for (Fault fault : {Fault::StaleSsid, Fault::HiddenSsid, Fault::WrongChannel,
|
||||
Fault::WrongProtocol, Fault::MissingMode, Fault::NotStarted,
|
||||
Fault::ZeroIp, Fault::SetterFailure, Fault::WrongAuth, Fault::StillConnected,
|
||||
Fault::ZeroIp, Fault::SetterFailure, Fault::WrongAuth, Fault::WrongPassword, Fault::StillConnected,
|
||||
Fault::ModeSetterFailure, Fault::IpConfigSetterFailure,
|
||||
Fault::StaProtocolSetterFailure, Fault::SdkModeReadFailure,
|
||||
Fault::SdkConfigReadFailure, Fault::SdkProtocolReadFailure}) {
|
||||
if (fault == Fault::WrongPassword && expectedAuth() == WIFI_AUTH_OPEN) continue;
|
||||
reset(fault); WiFi.shared_espnow = true;
|
||||
WebConfigServer portal;
|
||||
char reply[160] = {};
|
||||
|
||||
+13
-2
@@ -5,6 +5,9 @@
|
||||
#include <string>
|
||||
#include <ArduinoJson.h>
|
||||
#include <helpers/WebConfigLoginThrottle.h>
|
||||
#define MESH_MANAGEMENT_PASSWORD_HOST_TEST
|
||||
#include <helpers/esp32/ManagementPassword.h>
|
||||
namespace password = mesh::esp32::managementPassword;
|
||||
static uint32_t now_ms=100000, random_word=0x12345678;
|
||||
uint32_t millis() { return now_ms; }
|
||||
uint32_t esp_random() { return ++random_word; }
|
||||
@@ -37,11 +40,17 @@ struct WebConfigServer {
|
||||
enum { MODE_OFF, MODE_SETUP, MODE_LAN };
|
||||
int _mode=MODE_SETUP, _mux=0;
|
||||
char _wifi_ssid[33]="SavedNet", _session_token[33] = {};
|
||||
bool _initial_setup=false;
|
||||
bool _initial_setup=false, _setup_provisioning_privilege=false;
|
||||
uint32_t _last_activity=0, _session_last_seen=0;
|
||||
uint32_t _session_generation=0, _ap_auth_generation=0;
|
||||
WebConfigLoginThrottle::Tracker _login_throttle;
|
||||
Callback callback; Callback* _cb=&callback;
|
||||
WebConfigServer() { strcpy(callback.node.admin_password, "existing-secret"); }
|
||||
WebConfigServer() {
|
||||
// A candidate preference can differ while a save is in flight or failed.
|
||||
// The HTTP handler must authenticate the committed mirror instead.
|
||||
strcpy(callback.node.admin_password, "temporary-RAM");
|
||||
assert(password::publish("existing-secret"));
|
||||
}
|
||||
void noteNetworkActivity() {}
|
||||
void handleLogin(AsyncWebServerRequest*);
|
||||
};
|
||||
@@ -64,6 +73,8 @@ int main() {
|
||||
assert(login(portal, attacker, "existing-secret")==429 && last_reply==kLocked);
|
||||
assert(login(portal, owner, "existing-secret")==200);
|
||||
assert(strlen(portal._session_token)==32);
|
||||
password::Snapshot committed; assert(password::snapshot(committed));
|
||||
assert(portal._session_generation==committed.generation);
|
||||
assert(login(portal, attacker, "existing-secret")==429 && last_reply==kLocked);
|
||||
now_ms+=30000;
|
||||
assert(login(portal, attacker, "existing-secret")==200);
|
||||
|
||||
+20
@@ -42,19 +42,25 @@ struct WebConfigServer {
|
||||
Mode _mode = MODE_LAN;
|
||||
bool _stopping = false, _keep_wifi_on_stop = false, _was_setup_ap = false;
|
||||
bool _owns_wifi = true, _stop_warned = false, _initial_setup = false;
|
||||
bool _setup_provisioning_privilege = false;
|
||||
bool _retry_saved_wifi_in_setup = false, _setup_reconnect_in_progress = false;
|
||||
bool _batch_reboot_armed = false, _setup_wifi_handoff_pending = false;
|
||||
bool _admin_pwd_set = false, _board_cmds_probed = false;
|
||||
bool _batch_reauth_required = false, _batch_reauth_delivered = false;
|
||||
uint32_t _stop_warn_at = 0, _setup_started_at = 0, _connect_deadline = 0;
|
||||
uint32_t _setup_reconnect_deadline = 0, _reboot_at = 0, _batch_state = 0;
|
||||
uint32_t _batch_next = 0, _setup_wifi_handoff_deadline = 0, _batch_kind = 0;
|
||||
uint32_t _board_cmds = 0;
|
||||
uint32_t _network_activity_until = 0;
|
||||
uint32_t _ap_auth_generation = 0, _ap_rekey_generation = 0;
|
||||
uint32_t _ap_rekey_after = 0, _ap_rekey_deadline = 0;
|
||||
uint32_t _batch_reauth_until = 0, _session_generation = 0, _session_last_seen = 0;
|
||||
void* _mux = nullptr;
|
||||
struct Terminal { bool running = false; struct { bool pending = false; } queue; };
|
||||
Terminal* _terminal = nullptr;
|
||||
char _wifi_ssid[32] = {}, _setup_wifi_handoff_ip[16] = {};
|
||||
char _session_token[16] = {}, _stats_json[16] = {};
|
||||
char _batch_owner_token[33] = {};
|
||||
bool routes = true;
|
||||
void detachRoutes() { routes = false; }
|
||||
void closeTerminal() {}
|
||||
@@ -66,6 +72,7 @@ struct WebConfigServer {
|
||||
void requestStop();
|
||||
bool stopForOTA(char*);
|
||||
void finalizeTeardown();
|
||||
void clearSessionLocked();
|
||||
~WebConfigServer() { delete _room_mailbox; }
|
||||
};
|
||||
@METHODS@
|
||||
@@ -77,6 +84,13 @@ int main() {
|
||||
portal._owns_wifi = owns_wifi;
|
||||
portal._was_setup_ap = setup;
|
||||
portal._mode = setup ? WebConfigServer::MODE_SETUP : WebConfigServer::MODE_LAN;
|
||||
strcpy(portal._session_token, "old-session");
|
||||
strcpy(portal._batch_owner_token, "old-batch-session");
|
||||
portal._batch_reauth_required = true;
|
||||
portal._batch_reauth_delivered = portal._setup_provisioning_privilege = true;
|
||||
portal._ap_auth_generation = portal._ap_rekey_generation = 42;
|
||||
portal._ap_rekey_after = portal._ap_rekey_deadline = 42;
|
||||
portal._batch_reauth_until = portal._session_generation = portal._session_last_seen = 42;
|
||||
WiFi = WiFiFake{};
|
||||
WiFi.ap = setup;
|
||||
WiFi.sta = !setup;
|
||||
@@ -94,6 +108,12 @@ int main() {
|
||||
portal.requestStop(); // A duplicate stop cannot revoke the handoff.
|
||||
portal.finalizeTeardown(); // Existing requests have finally drained.
|
||||
assert(portal._room_mailbox == nullptr);
|
||||
assert(!portal._session_token[0] && !portal._batch_owner_token[0]);
|
||||
assert(!portal._batch_reauth_required && !portal._batch_reauth_until
|
||||
&& !portal._session_generation && !portal._session_last_seen);
|
||||
assert(!portal._batch_reauth_delivered && !portal._setup_provisioning_privilege);
|
||||
assert(!portal._ap_auth_generation && !portal._ap_rekey_generation
|
||||
&& !portal._ap_rekey_after && !portal._ap_rekey_deadline);
|
||||
assert(WiFi.ap && WiFi.sta && WiFi.stops == 0);
|
||||
assert(WiFi.ap_stops == unsigned(setup));
|
||||
assert(portal.callbacks.stops == 1 && !portal._keep_wifi_on_stop);
|
||||
|
||||
+79
@@ -0,0 +1,79 @@
|
||||
// Production generation gates and post-command commit handling, fake CLI only.
|
||||
#include <cassert>
|
||||
#include <cstdint>
|
||||
#include <cstdio>
|
||||
#include <cstring>
|
||||
#include <mutex>
|
||||
#define MESH_MANAGEMENT_PASSWORD_HOST_TEST
|
||||
#include <helpers/esp32/ManagementPassword.h>
|
||||
#include <helpers/WebConfigBatch.h>
|
||||
namespace password = mesh::esp32::managementPassword;
|
||||
using WCLock = std::lock_guard<std::mutex>;
|
||||
uint32_t millis() { return 1000; }
|
||||
struct Callbacks { unsigned starts = 0, ends = 0;
|
||||
void onConfigBatchStart() { ++starts; }
|
||||
void onConfigBatchEnd() { ++ends; }
|
||||
};
|
||||
struct WebConfigServer {
|
||||
struct BatchEntry { char reply[160] = {}; } _batch[3];
|
||||
enum State { BATCH_IDLE, BATCH_PENDING, BATCH_DONE } _batch_state = BATCH_PENDING;
|
||||
unsigned _batch_next = 0, _batch_count = 3, executed = 0, reloads = 0;
|
||||
bool _batch_started = false, _batch_all_ok = true, _standalone_wifi_dirty = false;
|
||||
bool _batch_reauth_required = false, _batch_reauth_delivered = false, post_completed = false;
|
||||
uint32_t _batch_auth_generation = 0, _batch_reauth_until = 0;
|
||||
uint32_t _session_generation = 0, _session_last_seen = 5;
|
||||
char _batch_owner_token[33] = {}, _session_token[33] = "0123456789abcdef0123456789abcdef";
|
||||
std::mutex _mux;
|
||||
Callbacks callbacks; Callbacks* _cb = &callbacks;
|
||||
WebConfigServer() { password::Snapshot value; assert(password::snapshot(value));
|
||||
_session_generation = value.generation; beginBatchAuthorizationLocked(value.generation); }
|
||||
void reloadStandaloneWiFi() { ++reloads; }
|
||||
void clearSessionLocked();
|
||||
void beginBatchAuthorizationLocked(uint32_t);
|
||||
void cancelStaleBatchLocked(uint32_t);
|
||||
void startBatch(uint32_t);
|
||||
void beforeCommand(uint32_t);
|
||||
void afterCommand(bool, const char*, const char*, uint32_t);
|
||||
};
|
||||
@METHODS@
|
||||
void reset() {
|
||||
auto& state = password::detail::state();
|
||||
{ password::detail::Lock lock(state); state.current = {}; state.changing = state.uploading = state.rotating = false; }
|
||||
assert(password::initialize("original"));
|
||||
}
|
||||
void commit(const char* secret) {
|
||||
password::ChangeGuard change; assert(change.acquired()); assert(change.publish(secret));
|
||||
}
|
||||
int main() {
|
||||
reset(); WebConfigServer saved; saved.startBatch(100); assert(saved.callbacks.starts == 1);
|
||||
saved.beforeCommand(100); assert(saved.executed == 1);
|
||||
commit("next-password"); saved._batch_next = 1;
|
||||
saved.afterCommand(true, "next-password", "OK", 100);
|
||||
assert(saved.post_completed && saved._batch_reauth_required && saved._batch_reauth_until == 121000);
|
||||
assert(!saved._session_token[0] && saved._session_generation == 0 && saved._session_last_seen == 0);
|
||||
assert(saved._batch_owner_token[0]); // Only the scoped result receipt survives.
|
||||
saved.beforeCommand(200); assert(saved.executed == 2); // Own batch may continue.
|
||||
// Failed persistence changes no committed generation or general login.
|
||||
reset(); WebConfigServer failed; failed.startBatch(100);
|
||||
failed.afterCommand(true, "next-password", "Error: save unconfirmed", 100);
|
||||
assert(failed.post_completed && !failed._batch_reauth_required && failed._session_token[0]);
|
||||
// Saving the same password is a successful no-op, not a session rotation.
|
||||
commit("original"); failed.afterCommand(true, "original", "OK", 100);
|
||||
assert(!failed._batch_reauth_required && failed._session_token[0]);
|
||||
// A different transport's change before any command prevents callback entry.
|
||||
reset(); WebConfigServer stale; commit("external"); stale.startBatch(100);
|
||||
assert(stale.callbacks.starts == 0 && stale._batch_state == WebConfigServer::BATCH_DONE);
|
||||
assert(stale._batch_next == 3 && !stale._batch_all_ok && !stale._batch_owner_token[0]);
|
||||
for (const auto& entry : stale._batch) assert(strstr(entry.reply, "sign in again"));
|
||||
// A change between two commands balances the transaction and reloads staged WiFi.
|
||||
reset(); WebConfigServer between; between.startBatch(100); between._batch_next = 1;
|
||||
between._standalone_wifi_dirty = true; commit("external"); between.beforeCommand(200);
|
||||
assert(between.executed == 0 && between.callbacks.ends == 1 && between.reloads == 1);
|
||||
assert(!between._batch_started && !between._batch_reauth_required && !between._batch_all_ok);
|
||||
// A mismatched successful-looking reply cannot mint a receipt for another save.
|
||||
reset(); WebConfigServer mismatch; mismatch.startBatch(100); commit("external");
|
||||
mismatch.afterCommand(true, "requested", "OK", 100);
|
||||
assert(!mismatch.post_completed && !mismatch._batch_reauth_required && !mismatch._batch_owner_token[0]);
|
||||
assert(mismatch.callbacks.ends == 1 && mismatch._batch_state == WebConfigServer::BATCH_DONE);
|
||||
puts("WebConfig committed password/batch generation checks passed");
|
||||
}
|
||||
+45
@@ -0,0 +1,45 @@
|
||||
// Execute production result projection with real JSON. Auth capability gates
|
||||
// are exercised separately by webconfig_recovery_auth.cpp.
|
||||
#include <cassert>
|
||||
#include <cstring>
|
||||
#include <string>
|
||||
#include <ArduinoJson.h>
|
||||
#include <helpers/WebConfigBatch.h>
|
||||
struct Entry { char key[16]; char reply[160]; };
|
||||
struct Results {
|
||||
int _batch_count = 4;
|
||||
Entry _batch[4] = {
|
||||
{"password", "OK"},
|
||||
{"other", "> arbitrary-private-value"},
|
||||
{"other", "Error: arbitrary-private-error"},
|
||||
{"other", "OK - arbitrary-private-success"}
|
||||
};
|
||||
std::string config(bool authenticated) {
|
||||
DynamicJsonDocument doc(4096);
|
||||
JsonArray results = doc.createNestedArray("results");
|
||||
@CONFIG_LOOP@
|
||||
std::string out; serializeJson(doc, out); return out;
|
||||
}
|
||||
std::string cli(bool receipt_only) {
|
||||
const int from = 0, page = _batch_count;
|
||||
DynamicJsonDocument doc(4096);
|
||||
JsonArray results = doc.createNestedArray("results");
|
||||
@CLI_LOOP@
|
||||
std::string out; serializeJson(doc, out); return out;
|
||||
}
|
||||
};
|
||||
int main() {
|
||||
Results value;
|
||||
const auto config_receipt = value.config(false), cli_receipt = value.cli(true);
|
||||
for (const auto* receipt : {&config_receipt, &cli_receipt}) {
|
||||
assert(receipt->find("arbitrary-private") == std::string::npos);
|
||||
assert(receipt->find("OK") != std::string::npos);
|
||||
assert(receipt->find("sign in again") != std::string::npos);
|
||||
}
|
||||
assert(cli_receipt.find("Sign in again to read this result") != std::string::npos);
|
||||
// Fresh current-generation authentication permits the same batch to be
|
||||
// reread with complete details, without executing its commands again.
|
||||
assert(value.config(true).find("arbitrary-private-error") != std::string::npos);
|
||||
assert(value.cli(false).find("arbitrary-private-value") != std::string::npos);
|
||||
assert(value.cli(false).find("arbitrary-private-success") != std::string::npos);
|
||||
}
|
||||
+104
@@ -0,0 +1,104 @@
|
||||
// Only WiFi endpoints and clock are doubles; the service method is production.
|
||||
#include <cassert>
|
||||
#include <cstdint>
|
||||
#include <cstdio>
|
||||
#include <cstring>
|
||||
#include <mutex>
|
||||
#include <string>
|
||||
#define MESH_MANAGEMENT_PASSWORD_HOST_TEST
|
||||
#include <helpers/esp32/ManagementPassword.h>
|
||||
#include <helpers/WebConfigBatch.h>
|
||||
namespace password = mesh::esp32::managementPassword;
|
||||
using WCLock = std::lock_guard<std::mutex>;
|
||||
struct WiFiFake {
|
||||
bool ap_live = true, sta_live = true;
|
||||
unsigned disconnects = 0;
|
||||
bool softAPdisconnect(bool off) {
|
||||
assert(off); ++disconnects; ap_live = false; return true;
|
||||
}
|
||||
} WiFi;
|
||||
namespace mesh {
|
||||
struct Debug { void println(const char*) {} };
|
||||
Debug& usbDebugPort() { static Debug port; return port; }
|
||||
namespace wifi {
|
||||
bool ready = true;
|
||||
unsigned starts = 0;
|
||||
std::string last_password;
|
||||
bool startAccessPoint(const char* ssid, bool reuse, const char* secret) {
|
||||
assert(strcmp(ssid, "MC-1234") == 0 && reuse);
|
||||
++starts; last_password = secret ? secret : "";
|
||||
// Independent SDK model: mode(STA) may fail before touching the existing AP.
|
||||
if (!ready) return false;
|
||||
WiFi.ap_live = true; return true;
|
||||
}
|
||||
}}
|
||||
struct WebConfigServer {
|
||||
enum Mode { MODE_OFF, MODE_LAN, MODE_SETUP } _mode = MODE_SETUP;
|
||||
enum BatchState { BATCH_IDLE, BATCH_PENDING, BATCH_DONE } _batch_state = BATCH_IDLE;
|
||||
bool _stopping = false, _setup_provisioning_privilege = true;
|
||||
bool _batch_reauth_required = false, _batch_reauth_delivered = false;
|
||||
uint32_t _ap_auth_generation = 0, _ap_rekey_generation = 0;
|
||||
uint32_t _ap_rekey_after = 0, _ap_rekey_deadline = 0, _reboot_at = 0;
|
||||
char _ap_ssid[33] = "MC-1234";
|
||||
std::mutex _mux;
|
||||
unsigned refs = 0;
|
||||
unsigned handlerRefCount() const { return refs; }
|
||||
WebConfigServer() { password::Snapshot value; assert(password::snapshot(value)); _ap_auth_generation = value.generation; }
|
||||
void serviceSetupPassword(uint32_t now);
|
||||
};
|
||||
@METHODS@
|
||||
void reset() {
|
||||
auto& state = password::detail::state();
|
||||
{ password::detail::Lock lock(state); state.current = {}; state.changing = state.uploading = state.rotating = false; }
|
||||
assert(password::initialize("original"));
|
||||
WiFi = {}; mesh::wifi::ready = true; mesh::wifi::starts = 0; mesh::wifi::last_password.clear();
|
||||
}
|
||||
void commit(const char* secret) {
|
||||
password::ChangeGuard change; assert(change.acquired()); assert(change.publish(secret));
|
||||
}
|
||||
int main() {
|
||||
reset(); WebConfigServer portal;
|
||||
portal.serviceSetupPassword(100); assert(mesh::wifi::starts == 0);
|
||||
commit("next-password");
|
||||
portal._batch_state = WebConfigServer::BATCH_PENDING;
|
||||
portal.serviceSetupPassword(100); portal.serviceSetupPassword(4000);
|
||||
assert(mesh::wifi::starts == 0);
|
||||
portal._batch_state = WebConfigServer::BATCH_DONE; portal.refs = 1;
|
||||
portal.serviceSetupPassword(4000); assert(mesh::wifi::starts == 0);
|
||||
portal.refs = 0; portal._batch_reauth_required = true;
|
||||
portal.serviceSetupPassword(4000); assert(mesh::wifi::starts == 0);
|
||||
// Delivery gets a bounded grace period, then the live key follows the saved one.
|
||||
portal._batch_reauth_delivered = true; portal._ap_rekey_after = 7000;
|
||||
portal.serviceSetupPassword(6999); assert(mesh::wifi::starts == 0);
|
||||
password::Snapshot current; assert(password::snapshot(current));
|
||||
assert(password::beginUpload(current.generation));
|
||||
portal.serviceSetupPassword(7000); assert(mesh::wifi::starts == 0);
|
||||
password::endUpload(); portal.serviceSetupPassword(7000);
|
||||
assert(mesh::wifi::starts == 1 && mesh::wifi::last_password == "next-password");
|
||||
assert(portal._ap_auth_generation == current.generation && portal._ap_rekey_generation == 0);
|
||||
assert(WiFi.ap_live && WiFi.sta_live && WiFi.disconnects == 0);
|
||||
// A client that never receives its receipt cannot postpone rekey indefinitely.
|
||||
reset(); WebConfigServer no_receipt; commit("another-pass");
|
||||
no_receipt._batch_reauth_required = true;
|
||||
no_receipt.serviceSetupPassword(100); no_receipt.serviceSetupPassword(30099);
|
||||
assert(mesh::wifi::starts == 0);
|
||||
no_receipt.serviceSetupPassword(30100); assert(mesh::wifi::starts == 1);
|
||||
// Legacy short/non-ASCII admin credentials retain HTTP access on an open AP.
|
||||
for (const char* secret : {"short", "long-\xc3\xa9-pass"}) {
|
||||
reset(); WebConfigServer legacy; commit(secret);
|
||||
legacy.serviceSetupPassword(100); legacy.serviceSetupPassword(3100);
|
||||
assert(mesh::wifi::starts == 1 && mesh::wifi::last_password.empty());
|
||||
assert(!legacy._setup_provisioning_privilege && WiFi.ap_live && WiFi.sta_live);
|
||||
}
|
||||
// A failed restart must not leave an indefinitely usable old-key AP behind.
|
||||
reset(); WebConfigServer failed; const auto old_generation = failed._ap_auth_generation;
|
||||
commit("next-password"); mesh::wifi::ready = false;
|
||||
failed.serviceSetupPassword(100); failed.serviceSetupPassword(3100);
|
||||
assert(mesh::wifi::starts == 1 && !WiFi.ap_live && WiFi.sta_live && WiFi.disconnects == 1);
|
||||
assert(failed._ap_auth_generation == old_generation && failed._ap_rekey_generation != 0);
|
||||
failed.serviceSetupPassword(8099); assert(mesh::wifi::starts == 1);
|
||||
mesh::wifi::ready = true; failed.serviceSetupPassword(8100);
|
||||
assert(mesh::wifi::starts == 2 && WiFi.ap_live && WiFi.sta_live);
|
||||
assert(failed._ap_auth_generation != old_generation && failed._ap_rekey_generation == 0);
|
||||
puts("WebConfig password session/rekey checks passed");
|
||||
}
|
||||
+128
-7
@@ -5,6 +5,23 @@
|
||||
#include <string>
|
||||
#include <ArduinoJson.h>
|
||||
#include <helpers/WebConfigLoginThrottle.h>
|
||||
#include <helpers/WebConfigBatch.h>
|
||||
#define MESH_MANAGEMENT_PASSWORD_HOST_TEST
|
||||
#include <helpers/esp32/ManagementPassword.h>
|
||||
namespace password = mesh::esp32::managementPassword;
|
||||
static void resetCredential(const char* value) {
|
||||
{
|
||||
auto& state = password::detail::state();
|
||||
password::detail::Lock lock(state);
|
||||
state.current = password::Snapshot{};
|
||||
state.changing = state.uploading = state.rotating = false;
|
||||
}
|
||||
assert(password::initialize(value));
|
||||
}
|
||||
static void commitCredential(const char* value) {
|
||||
password::ChangeGuard change;
|
||||
assert(change.acquired() && change.publish(value));
|
||||
}
|
||||
struct String : std::string {
|
||||
using std::string::string;
|
||||
String(std::string value) : std::string(std::move(value)) {}
|
||||
@@ -12,15 +29,22 @@ struct String : std::string {
|
||||
String substring(int begin, int end) const { return substr(begin, end-begin); }
|
||||
};
|
||||
static uint32_t now_ms=10, random_word=0x12345678;
|
||||
static bool rotate_during_login=false;
|
||||
uint32_t millis() { return now_ms; }
|
||||
uint32_t esp_random() { return ++random_word; }
|
||||
uint32_t esp_random() {
|
||||
if (rotate_during_login) {
|
||||
rotate_during_login=false;
|
||||
commitCredential("rotated-secret");
|
||||
}
|
||||
return ++random_word;
|
||||
}
|
||||
static constexpr uint32_t WEBCONFIG_SESSION_TTL_MS=10000;
|
||||
struct NodeSnapshot { char admin_password[16] = {}; };
|
||||
struct Callback {
|
||||
NodeSnapshot node;
|
||||
void getNodeSnapshot(NodeSnapshot& result) { result=node; }
|
||||
};
|
||||
struct WCLock { explicit WCLock(int) {} };
|
||||
struct WCLock { std::unique_lock<std::mutex> lock; explicit WCLock(std::mutex& m) : lock(m) {} };
|
||||
struct WiFiMock { int softAPIP() const { return 4; } } WiFi;
|
||||
struct Client {
|
||||
int local_ip=4; uint32_t remote=0;
|
||||
@@ -46,15 +70,26 @@ struct AsyncWebServerRequest {
|
||||
};
|
||||
struct WebConfigServer {
|
||||
enum { MODE_OFF, MODE_SETUP, MODE_LAN };
|
||||
int _mode=MODE_SETUP, _mux=0;
|
||||
enum BatchKind { BATCH_CONFIG, BATCH_CLI };
|
||||
int _mode=MODE_SETUP;
|
||||
std::mutex _mux;
|
||||
char _wifi_ssid[33]="SavedNet", _session_token[33] = {};
|
||||
bool _initial_setup=false, config_accepted=false;
|
||||
bool _initial_setup=false, _setup_provisioning_privilege=false, config_accepted=false;
|
||||
uint32_t _last_activity=0, _session_last_seen=0;
|
||||
uint32_t _session_generation=0, _ap_auth_generation=0;
|
||||
uint32_t _batch_auth_generation=0, _batch_reauth_until=0;
|
||||
bool _batch_reauth_required=false, _batch_reauth_delivered=false, _batch_started=false;
|
||||
BatchKind _batch_kind=BATCH_CONFIG;
|
||||
char _batch_owner_token[33]={}, _batch_reqid[24]="0123456789abcdef";
|
||||
WebConfigLoginThrottle::Tracker _login_throttle; Callback callback; Callback* _cb=&callback;
|
||||
WebConfigServer() { strcpy(callback.node.admin_password, "existing-secret"); }
|
||||
WebConfigServer() { strcpy(callback.node.admin_password, "existing-secret"); resetCredential("existing-secret"); }
|
||||
unsigned network_activity = 0;
|
||||
void noteNetworkActivity() { ++network_activity; }
|
||||
bool checkAuth(AsyncWebServerRequest*);
|
||||
bool checkAuthLocked(AsyncWebServerRequest*, uint32_t* generation=nullptr);
|
||||
void clearSessionLocked();
|
||||
void beginBatchAuthorizationLocked(uint32_t);
|
||||
bool resultReceiptAllowedLocked(AsyncWebServerRequest*, const char*, BatchKind, uint32_t);
|
||||
void handleLogin(AsyncWebServerRequest*);
|
||||
void handleLogout(AsyncWebServerRequest*);
|
||||
void checkConfigPost(AsyncWebServerRequest*);
|
||||
@@ -101,14 +136,18 @@ int main() {
|
||||
// First provisioning remains possible and retains its AP-only privilege
|
||||
// through a save batch that changes SSID before the confirmation arrives.
|
||||
portal._mode=WebConfigServer::MODE_SETUP; portal._wifi_ssid[0]=0;
|
||||
portal._setup_provisioning_privilege=true;
|
||||
password::Snapshot credential; assert(password::snapshot(credential));
|
||||
portal._ap_auth_generation=credential.generation;
|
||||
portal._initial_setup=true; assert(portal.checkAuth(&attacker));
|
||||
strcpy(portal._wifi_ssid, "ProvisionedNet"); assert(portal.checkAuth(&attacker));
|
||||
portal.handleLogin(&attacker); assert(attacker.status==200);
|
||||
attacker.socket.local_ip=9; assert(!portal.checkAuth(&attacker));
|
||||
portal.handleLogin(&attacker); assert(attacker.status==401); attacker.socket.local_ip=4;
|
||||
// Companion images deliberately have no administrator password or CLI
|
||||
// password management. Keep that explicit no-password deployment policy.
|
||||
// Explicit legacy passwordless deployments retain their no-password policy.
|
||||
portal._initial_setup=false; portal.callback.node.admin_password[0]=0;
|
||||
portal._setup_provisioning_privilege=false;
|
||||
resetCredential("");
|
||||
assert(portal.checkAuth(&attacker)); portal.handleLogin(&attacker); assert(attacker.status==200);
|
||||
// A spent login budget blocks new logins without revoking an existing
|
||||
// authenticated session, on either the saved-network AP or the LAN.
|
||||
@@ -130,5 +169,87 @@ int main() {
|
||||
active.checkConfigPost(&owner); assert(active.config_accepted);
|
||||
assert(token==active._session_token);
|
||||
}
|
||||
for (int mode : {WebConfigServer::MODE_SETUP, WebConfigServer::MODE_LAN}) {
|
||||
WebConfigServer active; active._mode=mode;
|
||||
AsyncWebServerRequest owner; owner.password("existing-secret");
|
||||
active.handleLogin(&owner); assert(owner.status==200);
|
||||
owner.header.cookies=std::string("wcs=")+active._session_token;
|
||||
const std::string original_cookie=owner.header.cookies;
|
||||
password::Snapshot before; assert(password::snapshot(before));
|
||||
active.beginBatchAuthorizationLocked(before.generation);
|
||||
// A setter's temporary RAM and failed-save rollback never reach auth.
|
||||
strcpy(active.callback.node.admin_password, "temporary");
|
||||
assert(active.checkAuth(&owner));
|
||||
AsyncWebServerRequest unsaved; unsaved.password("temporary");
|
||||
active.handleLogin(&unsaved); assert(unsaved.status==401);
|
||||
strcpy(active.callback.node.admin_password, "existing-secret");
|
||||
commitCredential("existing-secret");
|
||||
assert(active.checkAuth(&owner)); // same credential is not a rotation
|
||||
|
||||
commitCredential("new-secret");
|
||||
password::Snapshot after; assert(password::snapshot(after));
|
||||
assert(after.generation!=before.generation);
|
||||
assert(!active.checkAuth(&owner));
|
||||
active.config_accepted=false; active.checkConfigPost(&owner);
|
||||
assert(owner.status==401 && !active.config_accepted);
|
||||
// Only an actual owning password batch grants this short-lived result
|
||||
// receipt; external rotation alone does not create one.
|
||||
assert(!active.resultReceiptAllowedLocked(&owner, active._batch_reqid,
|
||||
WebConfigServer::BATCH_CONFIG, now_ms));
|
||||
active._batch_auth_generation=after.generation;
|
||||
active._batch_reauth_required=true;
|
||||
active._batch_reauth_until=WebConfigBatch::scheduleAt(now_ms, WebConfigBatch::kReauthReceiptMs);
|
||||
assert(active.resultReceiptAllowedLocked(&owner, active._batch_reqid,
|
||||
WebConfigServer::BATCH_CONFIG, now_ms));
|
||||
assert(!active.resultReceiptAllowedLocked(&owner, "fedcba9876543210",
|
||||
WebConfigServer::BATCH_CONFIG, now_ms));
|
||||
assert(!active.resultReceiptAllowedLocked(&owner, active._batch_reqid,
|
||||
WebConfigServer::BATCH_CLI, now_ms));
|
||||
AsyncWebServerRequest other; other.header.cookies="wcs=00000000000000000000000000000000";
|
||||
assert(!active.resultReceiptAllowedLocked(&other, active._batch_reqid,
|
||||
WebConfigServer::BATCH_CONFIG, now_ms));
|
||||
assert(!active.resultReceiptAllowedLocked(&owner, active._batch_reqid,
|
||||
WebConfigServer::BATCH_CONFIG, active._batch_reauth_until));
|
||||
owner.password("existing-secret"); active.handleLogin(&owner); assert(owner.status==401);
|
||||
owner.password("new-secret"); active.handleLogin(&owner); assert(owner.status==200);
|
||||
owner.header.cookies=std::string("wcs=")+active._session_token;
|
||||
assert(active.checkAuth(&owner));
|
||||
owner.header.cookies=original_cookie; assert(!active.checkAuth(&owner));
|
||||
// Even changing back cannot revive the first generation's cookie/receipt.
|
||||
commitCredential("existing-secret");
|
||||
assert(!active.checkAuth(&owner));
|
||||
assert(!active.resultReceiptAllowedLocked(&owner, active._batch_reqid,
|
||||
WebConfigServer::BATCH_CONFIG, now_ms));
|
||||
}
|
||||
{
|
||||
WebConfigServer active;
|
||||
AsyncWebServerRequest first_setup;
|
||||
active._wifi_ssid[0]=0;
|
||||
active._initial_setup=active._setup_provisioning_privilege=true;
|
||||
password::Snapshot before; assert(password::snapshot(before));
|
||||
active._ap_auth_generation=before.generation;
|
||||
assert(active.checkAuth(&first_setup));
|
||||
active.beginBatchAuthorizationLocked(before.generation); // no initial cookie
|
||||
commitCredential("new-secret");
|
||||
assert(!active.checkAuth(&first_setup)); // old WPA association is not auth
|
||||
first_setup.password("new-secret"); active.handleLogin(&first_setup);
|
||||
assert(first_setup.status==200 && active._session_token[0]);
|
||||
first_setup.header.cookies=std::string("wcs=")+active._session_token;
|
||||
assert(active.checkAuth(&first_setup));
|
||||
}
|
||||
{
|
||||
WebConfigServer active;
|
||||
active._wifi_ssid[0]=0; active._initial_setup=true;
|
||||
commitCredential("short");
|
||||
AsyncWebServerRequest open_ap;
|
||||
assert(!active.checkAuth(&open_ap)); // short legacy password still needs HTTP login
|
||||
open_ap.password("short"); active.handleLogin(&open_ap); assert(open_ap.status==200);
|
||||
}
|
||||
{
|
||||
WebConfigServer active; AsyncWebServerRequest login;
|
||||
login.password("existing-secret"); rotate_during_login=true;
|
||||
active.handleLogin(&login);
|
||||
assert(login.status==401 && active._session_token[0]==0);
|
||||
}
|
||||
puts("WebConfig recovery authentication regression checks passed");
|
||||
}
|
||||
Vendored
+11
-2
@@ -9,6 +9,9 @@
|
||||
#include <initializer_list>
|
||||
#include <string>
|
||||
#include <helpers/WirelessControl.h>
|
||||
#define MESH_MANAGEMENT_PASSWORD_HOST_TEST 1
|
||||
#include <helpers/esp32/ManagementPassword.h>
|
||||
static const bool published_password = mesh::esp32::managementPassword::publish("short");
|
||||
|
||||
#define MESH_DEBUG_PRINTLN(...) ((void)0)
|
||||
constexpr int WL_CONNECTED = 3;
|
||||
@@ -17,7 +20,7 @@ using wifi_mode_t = int;
|
||||
constexpr int WIFI_OFF = 0, WIFI_STA = 1, WIFI_AP = 2;
|
||||
constexpr int WIFI_MODE_NULL = WIFI_OFF, WIFI_MODE_AP = WIFI_AP;
|
||||
constexpr int ESP_OK = 0, WIFI_IF_AP = 1, WIFI_IF_STA = 0;
|
||||
constexpr int WIFI_AUTH_OPEN = 0;
|
||||
constexpr int WIFI_AUTH_OPEN = 0, WIFI_AUTH_WPA2_PSK = 3;
|
||||
constexpr int WIFI_SCAN_RUNNING = -1, WIFI_SCAN_FAILED = -2, AP_STARTED_BIT = 1;
|
||||
constexpr int LISTEN = 10;
|
||||
bool async_bind_allowed = true;
|
||||
@@ -29,6 +32,7 @@ bool constrain_espnow_channel = true;
|
||||
struct wifi_config_t {
|
||||
struct {
|
||||
uint8_t ssid[32] = {};
|
||||
uint8_t password[64] = {};
|
||||
uint8_t ssid_len = 0, ssid_hidden = 0, channel = 1;
|
||||
int authmode = WIFI_AUTH_OPEN;
|
||||
} ap;
|
||||
@@ -86,11 +90,13 @@ struct WiFiFake {
|
||||
return allow_ap;
|
||||
}
|
||||
bool softAP(const char* ssid, const char* password, int channel = 1) {
|
||||
assert(ssid && strlen(ssid) <= 32 && password == nullptr);
|
||||
assert(ssid && strlen(ssid) <= 32);
|
||||
ap_channel = channel;
|
||||
++starts; ap = allow_ap; radio = true;
|
||||
if (ap) {
|
||||
live_config = {};
|
||||
live_config.ap.authmode = password && password[0] ? WIFI_AUTH_WPA2_PSK : WIFI_AUTH_OPEN;
|
||||
if (password) strcpy(reinterpret_cast<char*>(live_config.ap.password), password);
|
||||
const char* live_ssid = fault == Fault::Ssid ? "old-hotspot" : ssid;
|
||||
live_config.ap.ssid_len = uint8_t(strlen(live_ssid));
|
||||
memcpy(live_config.ap.ssid, live_ssid, strlen(live_ssid));
|
||||
@@ -181,6 +187,8 @@ struct AsyncWebServer {
|
||||
struct Elegant {
|
||||
bool busy = false;
|
||||
void setID(const char*) {}
|
||||
void useManagementPassword() {}
|
||||
bool authenticateRequest(AsyncWebServerRequest*) { return true; }
|
||||
void begin(AsyncWebServer*) {}
|
||||
bool setEnabled(bool enabled) { return enabled || !busy; }
|
||||
} AsyncElegantOTA;
|
||||
@@ -198,6 +206,7 @@ struct ESP32Board {
|
||||
const char* getManufacturerName() const { return "test"; }
|
||||
bool startOTAUpdate(const char*, char*, bool);
|
||||
bool stopOTAUpdate(char*);
|
||||
static bool rotateOTAAccessPoint(void*) { return true; }
|
||||
};
|
||||
struct LightweightServer {
|
||||
bool allow = true, running = false, allow_stop = true;
|
||||
|
||||
@@ -41,6 +41,7 @@ static std::atomic<bool> ota_upload_busy{false};"""
|
||||
rx=tcp[tcp.index(" // RX Timeout"):tcp.index(" // Everything is fine")]
|
||||
return (fixture.replace("@REBOOT_STATE@",state)
|
||||
.replace("@UPLOAD_STATE@",extract_braced(source,"struct OtaUploadState")+";")
|
||||
.replace("@AUTH@",extract_braced(source,"bool AsyncElegantOtaClass::authenticateRequest("))
|
||||
.replace("@BEGIN@",begin)
|
||||
.replace("@RESTART@",extract_braced(source,"void AsyncElegantOtaClass::restart()"))
|
||||
.replace("@ACCEPT_TIMEOUT@",accept).replace("@RX_TIMEOUT@",rx))
|
||||
@@ -68,6 +69,17 @@ static std::atomic<bool> ota_upload_busy{false};"""
|
||||
with self.assertRaises(AssertionError):
|
||||
self.compile_and_run(old,"-DESP32=1","-DTEST_OTA_BODY_TIMEOUT=1")
|
||||
|
||||
def test_management_auth_precedes_all_routes_and_upload_side_effects(self):
|
||||
fixture = self.uploader_fixture()
|
||||
self.compile_and_run(fixture, "-DESP32=1", "-DTEST_OTA_MANAGEMENT_PASSWORD=1")
|
||||
# Exercise the actual chunk gate: omitting authentication must admit a
|
||||
# wrong password and fail the zero-flash-effects assertion above.
|
||||
gate = " if (!authenticateRequest(request, &authenticated_generation)) return;"
|
||||
old = fixture.replace(gate, " authenticated_generation = mesh::esp32::managementPassword::detail::state().current.generation;")
|
||||
self.assertNotEqual(old, fixture)
|
||||
with self.assertRaises(AssertionError):
|
||||
self.compile_and_run(old, "-DESP32=1", "-DTEST_OTA_MANAGEMENT_PASSWORD=1")
|
||||
|
||||
def premature_commit_fixture(self, fixture):
|
||||
commit=extract_braced(fixture,"if (state && state->file_final")
|
||||
end=extract_braced(commit,"if (!Update.end(true))")
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Fault-inject the real Companion NVS credential store, including reboot reads."""
|
||||
from pathlib import Path
|
||||
import os
|
||||
import subprocess
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
|
||||
PREFERENCES = r'''
|
||||
#pragma once
|
||||
#include <string>
|
||||
#include <cstring>
|
||||
#include <algorithm>
|
||||
enum PreferenceType { PT_INVALID, PT_U8, PT_STR };
|
||||
struct Disk {
|
||||
bool begin_ok = true, has_password = false, has_pending = false;
|
||||
PreferenceType password_type = PT_STR, pending_type = PT_U8;
|
||||
std::string password;
|
||||
unsigned char pending = 0;
|
||||
int writes = 0, fail_write = 0, reads = 0, fail_read = 0;
|
||||
bool corrupt_read = false;
|
||||
} disk;
|
||||
class Preferences {
|
||||
public:
|
||||
bool begin(const char* name, bool read_only) {
|
||||
return !strcmp(name, "mesh-admin") && !read_only && disk.begin_ok;
|
||||
}
|
||||
void end() {}
|
||||
bool isKey(const char* name) { return !strcmp(name, "password") ? disk.has_password : disk.has_pending; }
|
||||
PreferenceType getType(const char* name) { return !strcmp(name, "password") ? disk.password_type : disk.pending_type; }
|
||||
unsigned char getUChar(const char*, unsigned char fallback) {
|
||||
if (++disk.reads == disk.fail_read || !disk.has_pending || disk.pending_type != PT_U8) return fallback;
|
||||
return disk.pending;
|
||||
}
|
||||
size_t putUChar(const char*, unsigned char value) {
|
||||
if (++disk.writes == disk.fail_write) return 0;
|
||||
disk.has_pending = true; disk.pending_type = PT_U8; disk.pending = value; return 1;
|
||||
}
|
||||
size_t putString(const char*, const char* value) {
|
||||
if (++disk.writes == disk.fail_write) return 0;
|
||||
disk.has_password = true; disk.password_type = PT_STR; disk.password = value; return strlen(value);
|
||||
}
|
||||
size_t getString(const char*, char* output, size_t size) {
|
||||
if (++disk.reads == disk.fail_read || disk.password.size() + 1 > size) return 0;
|
||||
memcpy(output, disk.password.c_str(), disk.password.size() + 1);
|
||||
if (disk.corrupt_read) output[0] ^= 1;
|
||||
return disk.password.size() + 1;
|
||||
}
|
||||
};
|
||||
'''
|
||||
|
||||
|
||||
class CompanionAdminPasswordTest(unittest.TestCase):
|
||||
def test_migration_rotation_power_loss_and_explicit_repair(self):
|
||||
source = r'''
|
||||
#include <helpers/esp32/CompanionAdminPassword.h>
|
||||
#include <cassert>
|
||||
using namespace mesh::esp32::companionAdminPassword;
|
||||
void fresh() { disk = Disk{}; }
|
||||
void reboot() { disk.writes = disk.reads = disk.fail_write = disk.fail_read = 0; disk.corrupt_read = false; }
|
||||
void unavailable() {
|
||||
char value[CAPACITY]; memset(value, 'X', sizeof(value));
|
||||
assert(!load(value, "factory"));
|
||||
for (char c : value) assert(c == 0);
|
||||
}
|
||||
int main() {
|
||||
char value[CAPACITY];
|
||||
fresh(); assert(load(value, "factory") && !strcmp(value, "factory"));
|
||||
assert(!disk.has_password); // Loading never overwrites saved state.
|
||||
for (const char* invalid : {"", "1234567890123456", "abc\rdef", "abc\ndef"}) {
|
||||
assert(!save(invalid)); assert(disk.writes == 0);
|
||||
assert(!load(value, invalid));
|
||||
}
|
||||
assert(!save(nullptr));
|
||||
for (const char* valid_password : {"a", "123456789012345", "a:b ", "\303\251"}) {
|
||||
assert(save(valid_password)); reboot();
|
||||
assert(load(value, "different-default") && !strcmp(value, valid_password));
|
||||
}
|
||||
fresh(); disk.begin_ok = false; unavailable(); assert(!save("replacement"));
|
||||
fresh(); disk.has_password = true;
|
||||
for (const char* malformed : {"", "1234567890123456", "bad\rpassword", "bad\npassword"}) {
|
||||
disk.password = malformed; unavailable();
|
||||
}
|
||||
disk.password = std::string("valid\0hidden", 12); unavailable();
|
||||
disk.password = "saved-password"; disk.password_type = PT_U8; unavailable();
|
||||
disk.password_type = PT_STR; disk.has_pending = true;
|
||||
disk.pending_type = PT_STR; unavailable();
|
||||
disk.pending_type = PT_U8; disk.pending = 1; unavailable();
|
||||
disk.pending = 0; assert(load(value, "factory") && !strcmp(value, "saved-password"));
|
||||
disk.has_password = false; unavailable(); // A partial save cannot become factory login.
|
||||
for (int failing_write = 1; failing_write <= 3; ++failing_write) {
|
||||
fresh(); assert(save("old-password")); reboot();
|
||||
disk.fail_write = failing_write;
|
||||
assert(!save("new-password")); reboot();
|
||||
if (failing_write == 1) assert(load(value, "factory") && !strcmp(value, "old-password"));
|
||||
else unavailable(); // Pending survives power loss / failed data or commit writes.
|
||||
assert(save("repaired")); reboot();
|
||||
assert(load(value, "factory") && !strcmp(value, "repaired"));
|
||||
}
|
||||
for (int failing_read = 1; failing_read <= 3; ++failing_read) {
|
||||
fresh(); assert(save("old-password")); reboot();
|
||||
disk.fail_read = failing_read; assert(!save("new-password")); reboot();
|
||||
if (failing_read < 3) unavailable();
|
||||
else assert(load(value, "factory") && !strcmp(value, "new-password"));
|
||||
// A failed final read is honestly unconfirmed: reboot may see the durable new value.
|
||||
}
|
||||
fresh(); disk.corrupt_read = true; assert(!save("new-password")); reboot(); unavailable();
|
||||
fresh(); assert(save("saved-password")); reboot();
|
||||
disk.fail_read = 1; unavailable(); reboot();
|
||||
disk.fail_read = 2; unavailable();
|
||||
}
|
||||
'''
|
||||
with tempfile.TemporaryDirectory(prefix="companion-admin-") as temporary:
|
||||
work = Path(temporary)
|
||||
(work / "Preferences.h").write_text(PREFERENCES)
|
||||
(work / "test.cpp").write_text(source)
|
||||
result = subprocess.run([os.environ.get("CXX", "g++"), "-std=c++11", "-Wall", "-Wextra", "-Werror",
|
||||
"-fsanitize=address,undefined", "-fno-sanitize-recover=all", "-fno-pie", "-no-pie",
|
||||
"-DESP32_PLATFORM=1", '-DWIFI_SSID=""', "-I", str(work), "-I", str(ROOT / "src"),
|
||||
str(work / "test.cpp"), "-o", str(work / "test")], capture_output=True, text=True, timeout=60)
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
result = subprocess.run([str(work / "test")], capture_output=True, text=True, timeout=15)
|
||||
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,94 @@
|
||||
"""Verify the actual middleware's cloned-library project-header dependency."""
|
||||
import fnmatch
|
||||
import importlib.util
|
||||
from pathlib import Path
|
||||
import shutil
|
||||
import subprocess
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
SPEC = importlib.util.spec_from_file_location("web_build_fix", ROOT / "scripts/esp32_async_web_fix.py")
|
||||
FIX = importlib.util.module_from_spec(SPEC)
|
||||
SPEC.loader.exec_module(FIX)
|
||||
|
||||
|
||||
class Node:
|
||||
def __init__(self, path):
|
||||
self.path = Path(path)
|
||||
|
||||
def srcnode(self):
|
||||
return self
|
||||
|
||||
def get_abspath(self):
|
||||
return str(self.path.resolve())
|
||||
|
||||
|
||||
class Clone:
|
||||
def __init__(self, source_dir, build_dir):
|
||||
self.variables = {"$PROJECT_DIR": str(ROOT), "$PROJECT_SRC_DIR": str(source_dir),
|
||||
"$BUILD_DIR": str(build_dir)}
|
||||
self.paths = []
|
||||
self.middleware = []
|
||||
|
||||
def subst(self, value):
|
||||
return self.variables[value]
|
||||
|
||||
def PrependUnique(self, **values):
|
||||
self.paths = [p for p in values["CPPPATH"] if p not in self.paths] + self.paths
|
||||
|
||||
def AppendUnique(self, **values):
|
||||
self.paths += [p for p in values["CPPPATH"] if p not in self.paths]
|
||||
|
||||
def File(self, value):
|
||||
return Node(value)
|
||||
|
||||
def AddBuildMiddleware(self, callback, pattern):
|
||||
self.middleware.append((callback, pattern))
|
||||
|
||||
|
||||
class AsyncWebBuildPathTest(unittest.TestCase):
|
||||
def test_cached_and_direct_library_clones_can_include_current_project_header(self):
|
||||
compiler = shutil.which("g++") or shutil.which("clang++")
|
||||
self.assertIsNotNone(compiler)
|
||||
with tempfile.TemporaryDirectory(prefix="ota-build-path-") as temporary:
|
||||
work = Path(temporary)
|
||||
source_dir = work / "custom application src"
|
||||
project_header = source_dir / "helpers/esp32/ManagementPassword.h"
|
||||
project_header.parent.mkdir(parents=True)
|
||||
shutil.copyfile(ROOT / "src/helpers/esp32/ManagementPassword.h", project_header)
|
||||
stale = work / "libdeps/target/AsyncElegantOTA/src/AsyncElegantOTA.cpp"
|
||||
stale.parent.mkdir(parents=True)
|
||||
stale.write_text("// obsolete cached dependency\n")
|
||||
direct = ROOT / "arch/esp32/AsyncElegantOTA/src/AsyncElegantOTA.cpp"
|
||||
for index, source in enumerate((stale, direct)):
|
||||
with self.subTest(source=source.name, cached=index == 0):
|
||||
clone = Clone(source_dir, work / ("build-" + str(index)))
|
||||
FIX.install(clone)
|
||||
matches = [(callback, pattern) for callback, pattern in clone.middleware
|
||||
if fnmatch.fnmatch(str(source), pattern)]
|
||||
self.assertEqual(len(matches), 1)
|
||||
patched = matches[0][0](clone, Node(source)).path
|
||||
self.assertEqual(patched.read_bytes(), direct.read_bytes())
|
||||
for header in ("AsyncElegantOTA.h", "Hash.h", "elegantWebpage.h"):
|
||||
self.assertEqual((patched.parent / header).read_bytes(), (direct.parent / header).read_bytes())
|
||||
self.assertIn(str(source_dir), clone.paths)
|
||||
probe = work / "probe.cpp"
|
||||
probe.write_text('#define MESH_MANAGEMENT_PASSWORD_HOST_TEST 1\n'
|
||||
'#include <helpers/esp32/ManagementPassword.h>\n'
|
||||
'int main() { return mesh::esp32::managementPassword::validPassword("password") ? 0 : 1; }\n')
|
||||
command = [compiler, "-std=c++11", *["-I" + path for path in clone.paths],
|
||||
"-fsyntax-only", str(probe)]
|
||||
result = subprocess.run(command, capture_output=True, text=True, timeout=30)
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
# Recreate the failed production clone by omitting only
|
||||
# the project src path. It must fail at the exact header.
|
||||
result = subprocess.run([part for part in command if part != "-I" + str(source_dir)],
|
||||
capture_output=True, text=True, timeout=30)
|
||||
self.assertNotEqual(result.returncode, 0)
|
||||
self.assertIn("helpers/esp32/ManagementPassword.h", result.stderr)
|
||||
self.assertEqual(stale.read_text(), "// obsolete cached dependency\n")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -18,6 +18,8 @@ FLAGS = ["-DESP32_PLATFORM=1", "-DESP_PLATFORM=1", "-DADMIN_PASSWORD=1",
|
||||
"-DWEBCONFIG_UNCONFIGURED_SETUP_TIMEOUT_MS=1800000"]
|
||||
|
||||
FIXTURE = r'''
|
||||
#define MESH_MANAGEMENT_PASSWORD_HOST_TEST 1
|
||||
#include <helpers/esp32/ManagementPassword.h>
|
||||
#include <cassert>
|
||||
#include <cstdint>
|
||||
#include <cstdio>
|
||||
@@ -101,6 +103,7 @@ struct Prefs {
|
||||
int bridge_enabled = 0, espnow_bridge_enabled = 0, rs232_bridge_enabled = 0;
|
||||
bool battery_alert_enabled = false;
|
||||
const char* node_name = "test";
|
||||
const char* password = "password";
|
||||
float freq = 915, bw = 125; uint8_t sf = 7, cr = 5, disable_fwd = 0;
|
||||
};
|
||||
struct ObserverPrefs { char wifi_ssid[33] = {}; };
|
||||
|
||||
@@ -0,0 +1,172 @@
|
||||
"""Execute the shared credential mirror, Basic parser, and upload/change leases."""
|
||||
import base64
|
||||
from pathlib import Path
|
||||
import os
|
||||
import subprocess
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
|
||||
|
||||
class ManagementPasswordTest(unittest.TestCase):
|
||||
def compile_run(self, source, extra_source=None):
|
||||
with tempfile.TemporaryDirectory(prefix="management-password-") as temporary:
|
||||
directory = Path(temporary)
|
||||
files = []
|
||||
for name, text in (("test.cpp", source), ("other.cpp", extra_source)):
|
||||
if text is not None:
|
||||
path = directory / name
|
||||
path.write_text(text)
|
||||
files.append(str(path))
|
||||
result = subprocess.run([os.environ.get("CXX", "g++"), "-std=c++11", "-Wall", "-Wextra", "-Werror",
|
||||
"-pthread", "-DMESH_MANAGEMENT_PASSWORD_HOST_TEST=1", "-I", str(ROOT / "src"),
|
||||
*files, "-o", str(directory / "test")], capture_output=True, text=True, timeout=45)
|
||||
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
|
||||
result = subprocess.run([str(directory / "test")], capture_output=True, text=True, timeout=15)
|
||||
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
|
||||
|
||||
def test_parser_exact_boundaries_and_unusual_credentials(self):
|
||||
cases = []
|
||||
for password in ("a", "1234567", "12345678", "123456789012345", "a:b\\;\" ", "ééééééé"):
|
||||
encoded = base64.b64encode(("admin:" + password).encode()).decode()
|
||||
literal = '"' + ''.join('\\%03o' % byte for byte in password.encode()) + '"'
|
||||
cases.append(f'assert(basicAuthorizationMatches("Basic {encoded}", {literal}));')
|
||||
cases.append(f'assert(basicAuthorizationMatches("bAsIc {encoded}", {literal}));')
|
||||
cases.append(f'assert(!basicAuthorizationMatches("Basic {encoded}x", {literal}));')
|
||||
wrong = base64.b64encode(("other:" + password).encode()).decode()
|
||||
cases.append(f'assert(!basicAuthorizationMatches("Basic {wrong}", {literal}));')
|
||||
self.compile_run(r'''
|
||||
#include <helpers/esp32/ManagementPassword.h>
|
||||
#include <cassert>
|
||||
using namespace mesh::esp32::managementPassword;
|
||||
int main() {
|
||||
assert(!validPassword(nullptr) && !validPassword("") && !validPassword("1234567890123456"));
|
||||
assert(!validPassword("abc\rdef") && !validPassword("abc\ndef"));
|
||||
assert(validPassword("123456789012345") && validPassword("\303\251"));
|
||||
assert(!apPasswordEligible("1234567") && apPasswordEligible("12345678"));
|
||||
assert(apPasswordEligible("123456789012345") && !apPasswordEligible("1234567890123456"));
|
||||
assert(!apPasswordEligible("1234567\177") && !apPasswordEligible("1234567\303\251"));
|
||||
assert(!basicAuthorizationMatches(nullptr, "password"));
|
||||
assert(!basicAuthorizationMatches("Basic YWRtaW46", ""));
|
||||
@CASES@
|
||||
}
|
||||
'''.replace("@CASES@", "\n".join(cases)))
|
||||
|
||||
def test_empty_legacy_mirror_then_committed_rotation_and_upload_reservations(self):
|
||||
self.compile_run(r'''
|
||||
#include <helpers/esp32/ManagementPassword.h>
|
||||
#include <cassert>
|
||||
using namespace mesh::esp32::managementPassword;
|
||||
int main() {
|
||||
Snapshot before, after;
|
||||
assert(!snapshot(before));
|
||||
assert(!publish("") && initialize("") && snapshot(before) && before.generation == 1);
|
||||
assert(!available() && authenticateBasic("Basic YWRtaW46", 100) == AuthResult::Unavailable);
|
||||
assert(!initialize("different") && !publish("bad\npassword"));
|
||||
assert(publish("password") && snapshot(before) && before.generation == 2);
|
||||
assert(available() && publish("password") && snapshot(after) && after.generation == before.generation);
|
||||
{
|
||||
ChangeGuard failed_save;
|
||||
assert(failed_save.acquired());
|
||||
assert(!beginUpload(before.generation));
|
||||
assert(!publish("unsaved"));
|
||||
ChangeGuard competing; assert(!competing.acquired());
|
||||
// Simulate the caller's persistent save failing: do not publish.
|
||||
}
|
||||
assert(snapshot(after) && !strcmp(after.password, "password") && after.generation == before.generation);
|
||||
{
|
||||
UploadGuard upload(before.generation);
|
||||
assert(upload.acquired() && uploadActive());
|
||||
ChangeGuard change; assert(!change.acquired());
|
||||
assert(!publish("different"));
|
||||
UploadGuard competitor(before.generation); assert(!competitor.acquired());
|
||||
assert(authenticateBasic("Basic YWRtaW46cGFzc3dvcmQ=", 100) == AuthResult::Authorized);
|
||||
}
|
||||
assert(!uploadActive());
|
||||
{
|
||||
ChangeGuard saved; assert(saved.acquired());
|
||||
assert(!saved.publish("") && saved.publish("different"));
|
||||
assert(!beginUpload(before.generation));
|
||||
}
|
||||
assert(snapshot(after) && after.generation == before.generation + 1);
|
||||
assert(!beginUpload(before.generation));
|
||||
assert(authenticateBasic("Basic YWRtaW46cGFzc3dvcmQ=", 101) == AuthResult::Unauthorized);
|
||||
uint32_t generation = 0;
|
||||
assert(authenticateBasic("Basic YWRtaW46ZGlmZmVyZW50", 102, &generation) == AuthResult::Authorized);
|
||||
assert(generation == after.generation);
|
||||
}
|
||||
''')
|
||||
|
||||
def test_bounded_login_budget_and_prompt(self):
|
||||
self.compile_run(r'''
|
||||
#include <helpers/esp32/ManagementPassword.h>
|
||||
#include <cassert>
|
||||
using namespace mesh::esp32::managementPassword;
|
||||
int main() {
|
||||
assert(publish("password"));
|
||||
for (int i = 0; i != 20; ++i) assert(authenticateBasic(nullptr, 100) == AuthResult::Unauthorized);
|
||||
for (int i = 0; i != 5; ++i) assert(authenticateBasic("Basic wrong", 100) == AuthResult::Unauthorized);
|
||||
assert(authenticateBasic("Basic YWRtaW46cGFzc3dvcmQ=", 101) == AuthResult::Throttled);
|
||||
assert(authenticateBasic("Basic YWRtaW46cGFzc3dvcmQ=", 30100) == AuthResult::Authorized);
|
||||
}
|
||||
''')
|
||||
|
||||
def test_deferred_ap_rotation_is_owner_bound_and_excludes_uploads(self):
|
||||
self.compile_run(r'''
|
||||
#include <helpers/esp32/ManagementPassword.h>
|
||||
#include <cassert>
|
||||
using namespace mesh::esp32::managementPassword;
|
||||
int calls = 0;
|
||||
bool rotate(void* owner) {
|
||||
assert(owner == &calls); ++calls;
|
||||
Snapshot current; assert(snapshot(current) && !strcmp(current.password, "new-password"));
|
||||
ChangeGuard change; assert(!change.acquired());
|
||||
UploadGuard upload(current.generation); assert(!upload.acquired());
|
||||
return true;
|
||||
}
|
||||
int main() {
|
||||
assert(publish("password"));
|
||||
Snapshot current; snapshot(current);
|
||||
watchOtaAccessPoint(&calls, rotate, current.generation);
|
||||
assert(publish("new-password"));
|
||||
service(0xfffffff0U); service(733); assert(calls == 0);
|
||||
service(734); assert(calls == 1); service(5000); assert(calls == 1);
|
||||
snapshot(current); watchOtaAccessPoint(&calls, rotate, current.generation);
|
||||
assert(publish("third-value"));
|
||||
forgetOtaAccessPoint(reinterpret_cast<void*>(1));
|
||||
forgetOtaAccessPoint(&calls); service(6000); service(7000); assert(calls == 1);
|
||||
}
|
||||
''')
|
||||
|
||||
def test_one_registry_across_translation_units_and_threaded_snapshots(self):
|
||||
other = r'''
|
||||
#include <helpers/esp32/ManagementPassword.h>
|
||||
bool other_publish() { return mesh::esp32::managementPassword::publish("from-other"); }
|
||||
'''
|
||||
self.compile_run(r'''
|
||||
#include <helpers/esp32/ManagementPassword.h>
|
||||
#include <cassert>
|
||||
#include <atomic>
|
||||
#include <thread>
|
||||
using namespace mesh::esp32::managementPassword;
|
||||
bool other_publish();
|
||||
int main() {
|
||||
assert(other_publish()); Snapshot current; assert(snapshot(current) && !strcmp(current.password, "from-other"));
|
||||
std::atomic<bool> done(false);
|
||||
std::thread writer([&] {
|
||||
for (int i = 0; i < 10000; ++i) assert(publish(i & 1 ? "AAAAAAAAAAAAAAA" : "BBBBBBBBBBBBBBB"));
|
||||
done.store(true);
|
||||
});
|
||||
while (!done.load()) {
|
||||
assert(snapshot(current));
|
||||
assert(!strcmp(current.password, "from-other") || !strcmp(current.password, "AAAAAAAAAAAAAAA")
|
||||
|| !strcmp(current.password, "BBBBBBBBBBBBBBB"));
|
||||
}
|
||||
writer.join();
|
||||
}
|
||||
''', other)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -83,9 +83,50 @@ class HeltecV4WiFiSetupPageTest(unittest.TestCase):
|
||||
page = ui[page_start:page_end]
|
||||
self.assertLess(
|
||||
page.index("drawCompactCompanionWiFiSetupPage("),
|
||||
page.index("buildWiFiSetupQrPayload("),
|
||||
page.index("buildCurrentWiFiSetupQrPayload("),
|
||||
)
|
||||
|
||||
def test_qr_payload_uses_live_ap_password_and_refuses_unknown_state(self):
|
||||
header = (ROOT / "src/helpers/ui/WiFiSetupQrDisplay.h").read_text()
|
||||
helper = extract_braced(header, "inline bool buildCurrentWiFiSetupQrPayload(")
|
||||
source = r'''
|
||||
#include <cassert>
|
||||
#include <cstring>
|
||||
#include <helpers/ui/WiFiSetupQrPayload.h>
|
||||
#define WEBCONFIG_AP_PASSWORD "compiled-other"
|
||||
static bool known = true;
|
||||
static const char* live_password = "old;pass";
|
||||
namespace mesh { namespace wifi {
|
||||
bool accessPointJoinPassword(const char* ssid, char* out, size_t capacity) {
|
||||
if (!known || strcmp(ssid, "setup") || strlen(live_password) >= capacity) return false;
|
||||
strcpy(out, live_password); return true;
|
||||
}
|
||||
} namespace ui {
|
||||
@HELPER@
|
||||
} }
|
||||
int main() {
|
||||
char payload[256] = {};
|
||||
#if defined(ESP32_PLATFORM)
|
||||
assert(mesh::ui::buildCurrentWiFiSetupQrPayload(payload, sizeof(payload), "setup"));
|
||||
assert(!strcmp(payload, "WIFI:T:WPA;S:setup;P:old\\;pass;;"));
|
||||
live_password = "new-admin";
|
||||
assert(mesh::ui::buildCurrentWiFiSetupQrPayload(payload, sizeof(payload), "setup"));
|
||||
assert(!strcmp(payload, "WIFI:T:WPA;S:setup;P:new-admin;;"));
|
||||
live_password = "";
|
||||
assert(mesh::ui::buildCurrentWiFiSetupQrPayload(payload, sizeof(payload), "setup"));
|
||||
assert(!strcmp(payload, "WIFI:S:setup;;"));
|
||||
assert(!mesh::ui::buildCurrentWiFiSetupQrPayload(payload, sizeof(payload), "wrong"));
|
||||
known = false;
|
||||
assert(!mesh::ui::buildCurrentWiFiSetupQrPayload(payload, sizeof(payload), "setup"));
|
||||
#else
|
||||
assert(mesh::ui::buildCurrentWiFiSetupQrPayload(payload, sizeof(payload), "setup"));
|
||||
assert(!strcmp(payload, "WIFI:T:WPA;S:setup;P:compiled-other;;"));
|
||||
#endif
|
||||
}
|
||||
'''.replace("@HELPER@", helper)
|
||||
self.compile_and_run(source, "-DESP32_PLATFORM=1")
|
||||
self.compile_and_run(source)
|
||||
|
||||
def test_short_click_leaves_page_and_hold_toggles_session_ap(self):
|
||||
ui = UI.read_text(encoding="utf-8")
|
||||
home = ui[ui.index("class HomeScreen :") :]
|
||||
|
||||
@@ -184,7 +184,7 @@ int main() {
|
||||
self.assertIn("37, setup_ssid", page)
|
||||
self.assertIn("static constexpr int qr_size = 105;", page)
|
||||
self.assertIn("payload, qr_x, 55, qr_size", page)
|
||||
self.assertIn("mesh::ui::buildWiFiSetupQrPayload", page)
|
||||
self.assertIn("mesh::ui::buildCurrentWiFiSetupQrPayload", page)
|
||||
self.assertIn("display.setCompactText(true);", page)
|
||||
self.assertGreaterEqual(page.count("display.setCompactText(false);"), 2)
|
||||
self.assertNotIn("startFrame", page)
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
"""Run the production lightweight HTTP parser/upload gate with in-memory sockets."""
|
||||
from pathlib import Path
|
||||
import unittest
|
||||
|
||||
from test_replay_reset_integration import extract_braced
|
||||
import test_wifi_ota_start as wifi_start
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
|
||||
FIXTURE = r'''
|
||||
#define MESH_MANAGEMENT_PASSWORD_HOST_TEST 1
|
||||
#include <helpers/esp32/ManagementPassword.h>
|
||||
#include <cassert>
|
||||
#include <cstdint>
|
||||
#include <cstdio>
|
||||
#include <cstdlib>
|
||||
#include <cstring>
|
||||
#include <cstdarg>
|
||||
#include <string>
|
||||
#include <strings.h>
|
||||
uint32_t clock_ms = 100;
|
||||
uint32_t millis() { return clock_ms; }
|
||||
void delay(unsigned time) { clock_ms += time; }
|
||||
int reboots = 0;
|
||||
void esp_restart() { ++reboots; }
|
||||
constexpr int U_FLASH = 0;
|
||||
struct WiFiClient {
|
||||
std::string input, output;
|
||||
size_t position = 0;
|
||||
bool open = true;
|
||||
explicit WiFiClient(const std::string& wire) : input(wire) {}
|
||||
bool connected() const { return open; }
|
||||
int available() const { return input.size() - position; }
|
||||
int read() { return available() ? uint8_t(input[position++]) : -1; }
|
||||
int read(uint8_t* out, size_t count) {
|
||||
count = count < size_t(available()) ? count : available();
|
||||
memcpy(out, input.data() + position, count); position += count; return count;
|
||||
}
|
||||
void print(const char* value) { output += value; }
|
||||
void printf(const char* format, ...) {
|
||||
char text[512]; va_list args; va_start(args, format);
|
||||
int count = vsnprintf(text, sizeof(text), format, args); va_end(args);
|
||||
assert(count >= 0 && size_t(count) < sizeof(text)); output.append(text, count);
|
||||
}
|
||||
size_t write(const uint8_t* value, size_t count) { output.append(reinterpret_cast<const char*>(value), count); return count; }
|
||||
void stop() { open = false; }
|
||||
};
|
||||
struct ESP32Board { void setInhibitSleep(bool) {} } board;
|
||||
struct UpdateFake {
|
||||
int begins = 0, writes = 0, ends = 0, aborts = 0;
|
||||
bool begin(size_t, int) { ++begins; return true; }
|
||||
size_t write(uint8_t*, size_t count) { ++writes; return count; }
|
||||
void abort() { ++aborts; }
|
||||
bool end() { ++ends; return true; }
|
||||
const char* errorString() { return "fixture"; }
|
||||
} Update;
|
||||
class LightweightOTAServer {
|
||||
public:
|
||||
bool running = true;
|
||||
ESP32Board* board = &::board;
|
||||
int pages = 0, logs = 0;
|
||||
void sendPage(WiFiClient& client) { ++pages; client.print("HTTP/1.1 200 OK"); }
|
||||
void sendLog(WiFiClient& client) { ++logs; client.print("HTTP/1.1 200 OK"); }
|
||||
@METHODS@
|
||||
};
|
||||
int main() {
|
||||
using namespace mesh::esp32::managementPassword;
|
||||
LightweightOTAServer server;
|
||||
auto request = [&](const char* method, const char* path, const std::string& headers, const std::string& body = "") {
|
||||
WiFiClient client(std::string(method) + " " + path + " HTTP/1.1\r\n" + headers + "\r\n" + body);
|
||||
server.handleClient(client); return client.output;
|
||||
};
|
||||
assert(request("GET", "/update", "").find("503 Service Unavailable") != std::string::npos);
|
||||
assert(publish("password"));
|
||||
const std::string good = "Authorization: Basic YWRtaW46cGFzc3dvcmQ=\r\n";
|
||||
for (const char* path : {"/", "/update", "/log"}) {
|
||||
const auto absent = request("GET", path, "");
|
||||
assert(absent.find("401 Unauthorized") != std::string::npos
|
||||
&& absent.find("WWW-Authenticate: Basic realm=\"MeshCore admin\", charset=\"UTF-8\"") != std::string::npos);
|
||||
assert(request("GET", path, "Authorization: Basic wrong\r\n").find("401 Unauthorized") != std::string::npos);
|
||||
assert(request("GET", path, good).find("200 OK") != std::string::npos);
|
||||
}
|
||||
assert(server.pages == 2 && server.logs == 1 && Update.begins == 0);
|
||||
assert(request("GET", "/update", "Authorization:\r\n" + good).find("401 Unauthorized") != std::string::npos);
|
||||
assert(request("GET", "/update", std::string("Authorization: ") + std::string(100, 'x') + "\r\n").find("401 Unauthorized") != std::string::npos);
|
||||
const std::string payload(8, '\0');
|
||||
assert(request("POST", "/update", "Content-Length: 8\r\n", payload).find("401 Unauthorized") != std::string::npos);
|
||||
assert(Update.begins == 0 && Update.writes == 0 && reboots == 0);
|
||||
assert(publish("different"));
|
||||
assert(request("GET", "/log", good).find("401 Unauthorized") != std::string::npos);
|
||||
const std::string updated = "Authorization: Basic YWRtaW46ZGlmZmVyZW50\r\n";
|
||||
assert(request("GET", "/log", updated).find("200 OK") != std::string::npos);
|
||||
{
|
||||
ChangeGuard changing; assert(changing.acquired());
|
||||
assert(request("POST", "/update", updated + "Content-Length: 8\r\n", payload).find("409 Conflict") != std::string::npos);
|
||||
assert(!uploadActive() && Update.begins == 0);
|
||||
}
|
||||
assert(request("POST", "/update", updated + "Content-Length: 8\r\n", payload).find("200 OK") != std::string::npos);
|
||||
assert(Update.begins == 1 && Update.writes == 1 && Update.ends == 1 && reboots == 1 && !uploadActive());
|
||||
}
|
||||
'''
|
||||
|
||||
|
||||
class LightweightOtaAuthTest(unittest.TestCase):
|
||||
compile_and_run = wifi_start.WiFiOtaStartTest.compile_and_run
|
||||
|
||||
def test_http_routes_and_upload_admission_use_committed_admin(self):
|
||||
source = (ROOT / "src/helpers/ESP32Board.cpp").read_text()
|
||||
methods = "\n".join(extract_braced(source, signature) for signature in (
|
||||
" static void sendResponse(", " bool readLine(", " void sendUpdateError(",
|
||||
" void receiveUpdate(", " void handleClient("))
|
||||
fixture = FIXTURE.replace("@METHODS@", methods)
|
||||
self.compile_and_run(fixture)
|
||||
gate = " if (auth != mesh::esp32::managementPassword::AuthResult::Authorized) {"
|
||||
old = fixture.replace(gate, " if (false) {")
|
||||
self.assertNotEqual(old, fixture)
|
||||
with self.assertRaises(AssertionError):
|
||||
self.compile_and_run(old)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,106 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Exercise production role setters against failed saves and active flash uploads."""
|
||||
from pathlib import Path
|
||||
import os
|
||||
import subprocess
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
|
||||
|
||||
class ManagementPasswordCommandsTest(unittest.TestCase):
|
||||
def test_infrastructure_and_companion_publish_only_confirmed_saves(self):
|
||||
cli = (ROOT / "src/helpers/CommonCLI.cpp").read_text()
|
||||
start = cli.index(' } else if (memcmp(command, "password ", 9) == 0) {')
|
||||
end = cli.index(' } else if (memcmp(command, "clear stats",', start)
|
||||
infrastructure = cli[start:end].replace('} else if (', 'if (', 1) + '\n }\n'
|
||||
companion = (ROOT / "examples/companion_radio/MyMesh.cpp").read_text()
|
||||
start = companion.index('bool MyMesh::handleLocalControlCommand(')
|
||||
end = companion.index(' if (mesh::powerdiag::handleCommand(', start)
|
||||
companion = companion[start:end] + ' return false;\n}\n'
|
||||
source = r'''
|
||||
#include <helpers/esp32/ManagementPassword.h>
|
||||
#include <cassert>
|
||||
#include <cstdio>
|
||||
#include <cstring>
|
||||
#include <initializer_list>
|
||||
using namespace mesh::esp32::managementPassword;
|
||||
bool save_ok = true; int saves = 0;
|
||||
bool durableSave() {
|
||||
++saves; Snapshot current; assert(snapshot(current));
|
||||
UploadGuard blocked(current.generation); assert(!blocked.acquired());
|
||||
return save_ok;
|
||||
}
|
||||
namespace mesh { namespace esp32 { namespace companionAdminPassword {
|
||||
bool save(const char*) { return durableSave(); }
|
||||
} } }
|
||||
struct StrHelper {
|
||||
static void strncpy(char* out, const char* in, size_t size) { ::strncpy(out, in, size); out[size - 1] = 0; }
|
||||
};
|
||||
struct NodePrefs { char password[16] = "password"; };
|
||||
class CommonCLI {
|
||||
public:
|
||||
NodePrefs prefs; NodePrefs* _prefs = &prefs;
|
||||
bool trySavePrefs() { return durableSave(); }
|
||||
void command(char* command, char* reply) {
|
||||
@INFRASTRUCTURE@
|
||||
}
|
||||
};
|
||||
class MyMesh {
|
||||
public:
|
||||
char _management_password[16] = "password";
|
||||
bool _management_password_ready = true;
|
||||
bool handleLocalControlCommand(const char*, char*, size_t);
|
||||
void command(char* command, char* reply) { handleLocalControlCommand(command, reply, 160); }
|
||||
};
|
||||
@COMPANION@
|
||||
void unchanged(uint32_t generation) {
|
||||
Snapshot current; assert(snapshot(current));
|
||||
assert(current.generation == generation && !strcmp(current.password, "password"));
|
||||
}
|
||||
template <typename Role> void test(Role& role) {
|
||||
char command[80], reply[160]; Snapshot initial;
|
||||
assert(publish("password") && snapshot(initial));
|
||||
for (const char* value : {"", "1234567890123456", "bad\nvalue", "bad\rvalue"}) {
|
||||
snprintf(command, sizeof(command), "password %s", value);
|
||||
reply[0] = 0; const int before = saves; role.command(command, reply);
|
||||
assert(!strncmp(reply, "Error:", 6) && saves == before); unchanged(initial.generation);
|
||||
}
|
||||
save_ok = false; strcpy(command, "password replacement"); role.command(command, reply);
|
||||
assert(strstr(reply, "unconfirmed")); unchanged(initial.generation);
|
||||
save_ok = true;
|
||||
{
|
||||
UploadGuard upload(initial.generation); assert(upload.acquired());
|
||||
const int before = saves; role.command(command, reply);
|
||||
assert(strstr(reply, "busy") && saves == before); unchanged(initial.generation);
|
||||
}
|
||||
role.command(command, reply); assert(!strcmp(reply, "password now: replacement"));
|
||||
Snapshot changed; assert(snapshot(changed));
|
||||
assert(!strcmp(changed.password, "replacement") && changed.generation == initial.generation + 1);
|
||||
role.command(command, reply); assert(snapshot(changed) && changed.generation == initial.generation + 1);
|
||||
strcpy(command, "password leading space"); role.command(command, reply);
|
||||
assert(snapshot(changed) && !strcmp(changed.password, " leading space"));
|
||||
}
|
||||
int main() {
|
||||
CommonCLI infrastructure; test(infrastructure);
|
||||
assert(!strcmp(infrastructure.prefs.password, " leading space"));
|
||||
MyMesh companion; test(companion);
|
||||
assert(companion._management_password_ready && !strcmp(companion._management_password, " leading space"));
|
||||
}
|
||||
'''.replace("@INFRASTRUCTURE@", infrastructure).replace("@COMPANION@", companion)
|
||||
with tempfile.TemporaryDirectory(prefix="management-command-") as temporary:
|
||||
work = Path(temporary)
|
||||
(work / "test.cpp").write_text(source)
|
||||
result = subprocess.run([os.environ.get("CXX", "g++"), "-std=c++11", "-Wall", "-Wextra", "-Werror",
|
||||
"-fsanitize=address,undefined", "-fno-sanitize-recover=all", "-fno-pie", "-no-pie",
|
||||
"-DMESH_MANAGEMENT_PASSWORD_HOST_TEST=1", "-DESP_PLATFORM=1", "-DESP32_PLATFORM=1", '-DWIFI_SSID=""',
|
||||
"-I", str(ROOT / "src"), str(work / "test.cpp"), "-o", str(work / "test")],
|
||||
capture_output=True, text=True, timeout=60)
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
result = subprocess.run([str(work / "test")], capture_output=True, text=True, timeout=15)
|
||||
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,113 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Exercise production Repeater normalization before the actual password setter."""
|
||||
from pathlib import Path
|
||||
import os
|
||||
import subprocess
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
|
||||
|
||||
class RepeaterPasswordNormalizationTest(unittest.TestCase):
|
||||
def test_password_bytes_reach_the_setter_on_esp32_only(self):
|
||||
repeater = (ROOT / "examples/simple_repeater/MyMesh.cpp").read_text()
|
||||
start = repeater.index(" char* command_end = command + strlen(command);")
|
||||
end = repeater.index("\n\n", start)
|
||||
trimming = repeater[start:end]
|
||||
start = repeater.index(" while (*command == ' ') command++;", end)
|
||||
end = repeater.index(" mesh::cli::normalizeCommandVerb(command);", start)
|
||||
end += len(" mesh::cli::normalizeCommandVerb(command);")
|
||||
normalization = trimming + "\n" + repeater[start:end]
|
||||
cli = (ROOT / "src/helpers/CommonCLI.cpp").read_text()
|
||||
start = cli.index(' } else if (memcmp(command, "password ", 9) == 0) {')
|
||||
end = cli.index(' } else if (memcmp(command, "clear stats",', start)
|
||||
setter = cli[start:end].replace("} else if (", "if (", 1) + "\n }\n"
|
||||
source = r'''
|
||||
#include <helpers/CLICommandUtils.h>
|
||||
#include <helpers/esp32/ManagementPassword.h>
|
||||
#include <cassert>
|
||||
#include <cstdio>
|
||||
#include <cstring>
|
||||
using namespace mesh::esp32::managementPassword;
|
||||
char* normalize(char* command, char* reply) {
|
||||
@NORMALIZATION@
|
||||
return command;
|
||||
}
|
||||
struct StrHelper {
|
||||
static void strncpy(char* out, const char* in, size_t capacity) {
|
||||
::strncpy(out, in, capacity); out[capacity - 1] = 0;
|
||||
}
|
||||
};
|
||||
struct Prefs { char password[16] = "password"; };
|
||||
struct CommonCLI {
|
||||
Prefs prefs; Prefs* _prefs = &prefs; unsigned saves = 0;
|
||||
bool trySavePrefs() { ++saves; return true; }
|
||||
void command(char* command, char* reply) {
|
||||
@SETTER@
|
||||
}
|
||||
};
|
||||
void normalized(const char* input, const char* expected, const char* prefix = "") {
|
||||
char command[100], reply[160] = {};
|
||||
strcpy(command, input);
|
||||
assert(!strcmp(normalize(command, reply), expected));
|
||||
assert(!strcmp(reply, prefix));
|
||||
}
|
||||
int main() {
|
||||
normalized(" aB|Get name \t\r\n", "get name", "aB|");
|
||||
normalized(" Passwordx value \t\r\n", "passwordx value");
|
||||
#ifdef ESP_PLATFORM
|
||||
normalized(" aB|PaSsWoRd leading \t", "password leading \t", "aB|");
|
||||
normalized("password secret123 ", "password secret123 ");
|
||||
normalized("Password secret123\r\n", "password secret123\r\n");
|
||||
CommonCLI cli;
|
||||
assert(publish("password"));
|
||||
char command[100] = " aB|PaSsWoRd leading ", reply[160] = {};
|
||||
cli.command(normalize(command, reply), reply);
|
||||
assert(cli.saves == 1 && !strcmp(cli.prefs.password, " leading "));
|
||||
Snapshot current; assert(snapshot(current));
|
||||
assert(!strcmp(current.password, " leading "));
|
||||
const uint32_t generation = current.generation;
|
||||
for (const char* invalid : {"password secret123\r\n", "password bad\nvalue", "password bad\rvalue", "password "}) {
|
||||
strcpy(command, invalid);
|
||||
cli.command(normalize(command, reply), reply);
|
||||
assert(!strncmp(reply, "Error:", 6) && cli.saves == 1);
|
||||
assert(!strcmp(cli.prefs.password, " leading "));
|
||||
assert(snapshot(current) && current.generation == generation);
|
||||
}
|
||||
#else
|
||||
normalized(" aB|PaSsWoRd leading \t\r\n", "password leading", "aB|");
|
||||
normalized("password secret123 ", "password secret123");
|
||||
normalized("Password secret123\r\n", "password secret123");
|
||||
#endif
|
||||
}
|
||||
'''.replace("@NORMALIZATION@", normalization).replace("@SETTER@", setter)
|
||||
source = source.replace("#include <cassert>", "#include <cassert>\n#include <initializer_list>")
|
||||
with tempfile.TemporaryDirectory(prefix="repeater-password-") as temporary:
|
||||
work = Path(temporary)
|
||||
for platform in ("ESP_PLATFORM", "NRF52_PLATFORM", "STM32_PLATFORM"):
|
||||
with self.subTest(platform=platform):
|
||||
self._compile_and_run(work, source, platform)
|
||||
# The old blanket trim must demonstrably break this regression.
|
||||
mutant = source.replace('if (strcmp(command_verb, "password ") != 0)',
|
||||
'if (true)')
|
||||
result = self._compile_and_run(work, mutant, "ESP_PLATFORM", expect_pass=False)
|
||||
self.assertNotEqual(result.returncode, 0)
|
||||
|
||||
def _compile_and_run(self, work, source, platform, expect_pass=True):
|
||||
(work / "test.cpp").write_text(source)
|
||||
result = subprocess.run([
|
||||
os.environ.get("CXX", "g++"), "-std=c++11", "-Wall", "-Wextra", "-Werror",
|
||||
"-fsanitize=address,undefined", "-fno-sanitize-recover=all", "-fno-pie", "-no-pie",
|
||||
"-DMESH_MANAGEMENT_PASSWORD_HOST_TEST=1", f"-D{platform}=1",
|
||||
"-I", str(ROOT / "src"), str(work / "test.cpp"), "-o", str(work / "test"),
|
||||
], capture_output=True, text=True, timeout=60)
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
result = subprocess.run([str(work / "test")], capture_output=True, text=True, timeout=15)
|
||||
if expect_pass:
|
||||
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
|
||||
return result
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -130,6 +130,8 @@ for value in "${BUILD_APPLICATION_EXPECTATIONS[@]}"; do printf 'A:%s\0' "$value"
|
||||
start = source.index(' } else if (memcmp(command, "start ota", 9)')
|
||||
ota = source[start:source.index(' } else if (memcmp(command, "clock", 5)', start)]
|
||||
fixture = r'''
|
||||
#define MESH_MANAGEMENT_PASSWORD_HOST_TEST 1
|
||||
#include <helpers/esp32/ManagementPassword.h>
|
||||
#include <cassert>
|
||||
#include <cstdio>
|
||||
#include <cstring>
|
||||
@@ -153,7 +155,7 @@ struct Board {
|
||||
}
|
||||
bool stopOTAUpdate(char* reply) { ++stops; strcpy(reply, "Stopped OTA"); return true; }
|
||||
};
|
||||
struct Prefs { const char* node_name = "sensor"; };
|
||||
struct Prefs { const char* node_name = "sensor"; char password[16] = "password"; };
|
||||
struct CLI {
|
||||
Callbacks* _callbacks;
|
||||
Board* _board;
|
||||
|
||||
@@ -29,7 +29,7 @@ class WebConfigApStartTest(unittest.TestCase):
|
||||
helper += extract_braced(source, "static bool setupAccessPointReady(") + "\n"
|
||||
if "static bool scanSetupNetworks(" in source:
|
||||
helper += extract_braced(source, "static bool scanSetupNetworks(")
|
||||
method = extract_braced(source, "bool WebConfigServer::startSetupMode(")
|
||||
method = extract_braced(source, "void WebConfigServer::initializeManagementPassword(") + "\n" + extract_braced(source, "bool WebConfigServer::startSetupMode(")
|
||||
scan_handler = extract_braced(source, "void WebConfigServer::handleScan(")
|
||||
policy = (ROOT / "src/helpers/esp32/WiFiRadioPolicy.h").read_text(encoding="utf-8")
|
||||
mask = policy[policy.index("static constexpr uint8_t kAccessPointProtocolMask ="):]
|
||||
@@ -51,19 +51,22 @@ class WebConfigApStartTest(unittest.TestCase):
|
||||
binary = work / "webconfig-ap-start.exe"
|
||||
sanitizer = (["-fsanitize=address,undefined", "-fno-sanitize-recover=all",
|
||||
"-fno-pie", "-no-pie"] if sys.platform.startswith("linux") else [])
|
||||
for arduino_major, protected in ((2, False), (2, True), (3, False), (3, True)):
|
||||
with self.subTest(arduino_major=arduino_major, protected=protected):
|
||||
security_flags = ['-DWEBCONFIG_AP_PASSWORD="testpass"'] if protected else []
|
||||
compiled = subprocess.run([compiler, "-std=c++17", "-Wall", "-Wextra",
|
||||
*sanitizer, f"-DESP_ARDUINO_VERSION_MAJOR={arduino_major}",
|
||||
*security_flags,
|
||||
str(generated), "-o", str(binary)],
|
||||
capture_output=True, text=True, timeout=60)
|
||||
self.assertEqual(compiled.returncode, 0, compiled.stdout + compiled.stderr)
|
||||
checked = subprocess.run([str(binary)], capture_output=True,
|
||||
text=True, timeout=15)
|
||||
self.assertEqual(checked.returncode, 0, checked.stdout + checked.stderr)
|
||||
self.assertIn("WebConfig AP startup regression checks passed", checked.stdout)
|
||||
for arduino_major in (2, 3):
|
||||
for password in ("", "runtimepass", "short", "long-é-pass"):
|
||||
with self.subTest(arduino_major=arduino_major, password_kind="protected" if password == "runtimepass" else "legacy"):
|
||||
security_flags = ['-DTEST_ADMIN_PASSWORD="' + password + '"']
|
||||
# This obsolete build secret must never select AP security.
|
||||
security_flags += ['-DWEBCONFIG_AP_PASSWORD="unused-old-key"']
|
||||
compiled = subprocess.run([compiler, "-std=c++17", "-Wall", "-Wextra",
|
||||
*sanitizer, f"-DESP_ARDUINO_VERSION_MAJOR={arduino_major}",
|
||||
*security_flags, "-I" + str(ROOT / "src"),
|
||||
str(generated), "-o", str(binary)],
|
||||
capture_output=True, text=True, timeout=60)
|
||||
self.assertEqual(compiled.returncode, 0, compiled.stdout + compiled.stderr)
|
||||
checked = subprocess.run([str(binary)], capture_output=True,
|
||||
text=True, timeout=15)
|
||||
self.assertEqual(checked.returncode, 0, checked.stdout + checked.stderr)
|
||||
self.assertIn("WebConfig AP startup regression checks passed", checked.stdout)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -44,6 +44,10 @@ class LostReplyWebConfigTest(unittest.TestCase):
|
||||
allowed = extract_braced(server, "static bool isAllowedSetKey(")
|
||||
secret = extract_braced(server, "static bool isSecretKey(")
|
||||
post = extract_braced(server, "void WebConfigServer::handleConfigPost(")
|
||||
auth = "\n".join(extract_braced(server, signature) for signature in (
|
||||
"static bool fixedTimeEquals(", "static bool webConfigCookieMatches(",
|
||||
"void WebConfigServer::clearSessionLocked(", "bool WebConfigServer::checkAuthLocked(",
|
||||
"bool WebConfigServer::checkAuth(", "void WebConfigServer::beginBatchAuthorizationLocked("))
|
||||
get = extract_braced(server, "void WebConfigServer::handleConfigGet(")
|
||||
# Execute the actual snapshot-to-radio JSON branch. WiFi/MQTT fields
|
||||
# after this boundary are unrelated and require peripheral fixtures.
|
||||
@@ -61,11 +65,24 @@ class LostReplyWebConfigTest(unittest.TestCase):
|
||||
#include <cstring>
|
||||
#include <string>
|
||||
#include <ArduinoJson.h>
|
||||
#define MESH_MANAGEMENT_PASSWORD_HOST_TEST 1
|
||||
#include <helpers/esp32/ManagementPassword.h>
|
||||
#include "helpers/WebConfigKeys.h"
|
||||
#include "helpers/WebConfigBatch.h"
|
||||
using String = std::string;
|
||||
struct String : std::string {
|
||||
using std::string::string;
|
||||
String(std::string value) : std::string(std::move(value)) {}
|
||||
int indexOf(const char* value) const { auto pos = find(value); return pos == npos ? -1 : int(pos); }
|
||||
String substring(int begin, int end) const { return substr(begin, end - begin); }
|
||||
size_t write(uint8_t value) { push_back(char(value)); return 1; }
|
||||
size_t write(const uint8_t* data, size_t count) { append(reinterpret_cast<const char*>(data), count); return count; }
|
||||
};
|
||||
struct WCLock { explicit WCLock(int) {} };
|
||||
uint32_t millis() { return 1; }
|
||||
static constexpr uint32_t WEBCONFIG_SESSION_TTL_MS = 120000;
|
||||
struct WiFiMock { int softAPIP() const { return 4; } } WiFi;
|
||||
struct Client { int localIP() const { return 4; } };
|
||||
struct Header { String cookies = "wcs=0123456789abcdef0123456789abcdef"; const String& value() const { return cookies; } };
|
||||
namespace mesh {
|
||||
struct Port { template<typename... Args> void printf(const char*, Args...) {} };
|
||||
Port& usbDebugPort() { static Port port; return port; }
|
||||
@@ -75,11 +92,16 @@ static const char SECRET_SENTINEL[] = "********";
|
||||
@ALLOWED@
|
||||
@SECRET@
|
||||
struct AsyncWebServerRequest {
|
||||
Client socket;
|
||||
Header header;
|
||||
String input, output;
|
||||
void* _tempObject = nullptr;
|
||||
int status = 0;
|
||||
explicit AsyncWebServerRequest(String body = "") : input(body) { _tempObject = input.data(); }
|
||||
size_t contentLength() const { return input.size(); }
|
||||
Client* client() { return &socket; }
|
||||
bool hasHeader(const char*) const { return !header.cookies.empty(); }
|
||||
Header* getHeader(const char*) { return &header; }
|
||||
void send(int code, const char* = nullptr, const String& body = "") { status = code; output = body; }
|
||||
};
|
||||
struct WebConfigServer {
|
||||
@@ -95,9 +117,15 @@ struct WebConfigServer {
|
||||
static constexpr size_t MAX_BODY = 4096;
|
||||
static constexpr int MAX_BATCH = WebConfigBatch::kMaxBatch;
|
||||
int _mode = MODE_LAN, _mux = 0, _batch_kind = BATCH_CONFIG;
|
||||
bool authorized = true, _initial_setup = false, _batch_reboot = false;
|
||||
bool _initial_setup = false, _setup_provisioning_privilege = false, _batch_reboot = false;
|
||||
bool _batch_reboot_armed = false, _batch_all_ok = true;
|
||||
bool _standalone_wifi_dirty = false, _setup_wifi_handoff_pending = false;
|
||||
bool _batch_reauth_required = false, _batch_reauth_delivered = false, _batch_started = false;
|
||||
uint32_t _session_generation = 0, _session_last_seen = 1, _last_activity = 0;
|
||||
uint32_t _batch_auth_generation = 0, _batch_reauth_until = 0;
|
||||
uint32_t _ap_auth_generation = 0;
|
||||
char _session_token[33] = "0123456789abcdef0123456789abcdef";
|
||||
char _batch_owner_token[33] = {}, _wifi_ssid[33] = "fixture-network";
|
||||
uint32_t _setup_wifi_handoff_deadline = 0, _diag_until = 0;
|
||||
char _setup_wifi_handoff_ip[32] = {}, _batch_reqid[17] = {};
|
||||
int _batch_count = 0, _batch_next = 0;
|
||||
@@ -106,16 +134,27 @@ struct WebConfigServer {
|
||||
WebConfigBatch::State _batch_state = WebConfigBatch::State::Idle;
|
||||
static constexpr auto BATCH_PENDING = WebConfigBatch::State::Pending;
|
||||
static WebConfigBatch::State toSpecState(WebConfigBatch::State state) { return state; }
|
||||
bool checkAuth(AsyncWebServerRequest*) { return authorized; }
|
||||
WebConfigServer() {
|
||||
mesh::esp32::managementPassword::Snapshot current;
|
||||
assert(mesh::esp32::managementPassword::snapshot(current));
|
||||
_session_generation = current.generation;
|
||||
}
|
||||
void noteNetworkActivity() {}
|
||||
void clearSessionLocked();
|
||||
bool checkAuthLocked(AsyncWebServerRequest*, uint32_t* generation = nullptr);
|
||||
bool checkAuth(AsyncWebServerRequest*);
|
||||
void beginBatchAuthorizationLocked(uint32_t);
|
||||
void handleConfigGet(AsyncWebServerRequest*);
|
||||
void handleConfigPost(AsyncWebServerRequest*);
|
||||
};
|
||||
@AUTH_HELPERS@
|
||||
@GET@
|
||||
@POST@
|
||||
String writeBody(const char* value) {
|
||||
return String("{\"reqid\":\"0123456789abcdef\",\"set\":{\"lost.reply\":") + value + "}}";
|
||||
}
|
||||
int main() {
|
||||
assert(mesh::esp32::managementPassword::publish("password"));
|
||||
const char* modes[] = {"off", "no", "yes"};
|
||||
for (int value : {0, 1, 2, 255}) {
|
||||
WebConfigServer server;
|
||||
@@ -155,7 +194,7 @@ int main() {
|
||||
infrastructure.handleConfigPost(&forged);
|
||||
assert(forged.status == 400 && forged.output.find("setting unavailable") != String::npos);
|
||||
assert(infrastructure._batch_count == 0);
|
||||
infrastructure.authorized = false;
|
||||
infrastructure.clearSessionLocked();
|
||||
AsyncWebServerRequest unauthorized(writeBody("\"yes\""));
|
||||
infrastructure.handleConfigPost(&unauthorized);
|
||||
assert(unauthorized.status == 401 && infrastructure._batch_count == 0);
|
||||
@@ -164,7 +203,7 @@ int main() {
|
||||
}
|
||||
'''.replace("@CAPABILITY@", capability).replace("@SNAPSHOT@", snapshot).replace(
|
||||
"@BATCH_ENTRY@", batch_entry).replace("@ALLOWED@", allowed).replace(
|
||||
"@SECRET@", secret).replace("@GET@", get).replace("@POST@", post)
|
||||
"@SECRET@", secret).replace("@AUTH_HELPERS@", auth).replace("@GET@", get).replace("@POST@", post)
|
||||
|
||||
def test_actual_json_strings_and_capability_gated_settings_batch(self):
|
||||
# The shared compiler helper needs the production helper include path.
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Execute production credential-generation and AP-rekey decisions offline."""
|
||||
from pathlib import Path
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
from test_replay_reset_integration import extract_braced
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
|
||||
|
||||
class WebConfigPasswordSessionsTest(unittest.TestCase):
|
||||
def compile_run(self, source, expect_rejection=False, includes=()):
|
||||
compiler = shutil.which("g++") or shutil.which("clang++")
|
||||
self.assertIsNotNone(compiler)
|
||||
with tempfile.TemporaryDirectory(prefix="webconfig-password-sessions-") as directory:
|
||||
work = Path(directory)
|
||||
generated = work / "fixture.cpp"
|
||||
generated.write_text(source)
|
||||
binary = work / "fixture"
|
||||
sanitizer = (["-fsanitize=address,undefined", "-fno-sanitize-recover=all",
|
||||
"-fno-pie", "-no-pie"] if sys.platform.startswith("linux") else [])
|
||||
compiled = subprocess.run([compiler, "-std=c++17", "-Wall", "-Wextra",
|
||||
*sanitizer, "-I" + str(ROOT / "src"),
|
||||
*("-I" + str(path) for path in includes), str(generated), "-o", str(binary)],
|
||||
capture_output=True, text=True, timeout=60)
|
||||
self.assertEqual(compiled.returncode, 0, compiled.stderr)
|
||||
checked = subprocess.run([str(binary)], capture_output=True, text=True, timeout=10)
|
||||
if expect_rejection:
|
||||
self.assertNotEqual(checked.returncode, 0, checked.stdout + checked.stderr)
|
||||
self.assertIn("Assertion", checked.stderr)
|
||||
else:
|
||||
self.assertEqual(checked.returncode, 0, checked.stdout + checked.stderr)
|
||||
|
||||
def rekey_fixture(self):
|
||||
source = (ROOT / "src/helpers/esp32/WebConfigServer.cpp").read_text()
|
||||
method = extract_braced(source, "void WebConfigServer::serviceSetupPassword(")
|
||||
return (ROOT / "test/fixtures/webconfig_password_sessions.cpp").read_text().replace("@METHODS@", method)
|
||||
|
||||
def test_rekey_receipt_upload_and_failure_lifecycle(self):
|
||||
self.compile_run(self.rekey_fixture())
|
||||
|
||||
def test_failed_rekey_must_remove_old_key_access_point(self):
|
||||
fixture = self.rekey_fixture()
|
||||
# An early platform failure leaves the old AP intact unless the owner
|
||||
# explicitly removes it. Prove the fixture detects that original bug.
|
||||
old = fixture.replace(" WiFi.softAPdisconnect(true);\n", "", 1)
|
||||
self.assertNotEqual(old, fixture)
|
||||
self.compile_run(old, expect_rejection=True)
|
||||
|
||||
def batch_fixture(self):
|
||||
source = (ROOT / "src/helpers/esp32/WebConfigServer.cpp").read_text()
|
||||
methods = "\n".join(extract_braced(source, signature) for signature in (
|
||||
"static bool fixedTimeEquals(",
|
||||
"void WebConfigServer::clearSessionLocked(",
|
||||
"void WebConfigServer::beginBatchAuthorizationLocked(",
|
||||
"void WebConfigServer::cancelStaleBatchLocked("))
|
||||
drain = extract_braced(source, "void WebConfigServer::drainBatch(")
|
||||
start = drain[drain.index(" if (_batch_next == 0)"):drain.index(" } else if")]
|
||||
methods += "\nvoid WebConfigServer::startBatch(uint32_t now) {\n" + start + "\n }\n}\n"
|
||||
first_guard = drain.index(" WCLock prefs_lock", drain.index(" } else if"))
|
||||
guard = drain[first_guard:drain.index(" BatchEntry& e", first_guard)]
|
||||
methods += "\nvoid WebConfigServer::beforeCommand(uint32_t now) {\n" + guard + "\n++executed;\n}\n"
|
||||
post = drain[drain.index(" mesh::esp32::managementPassword::Snapshot committed;"):drain.index(" _batch_last_cmd =")]
|
||||
methods += "\nvoid WebConfigServer::afterCommand(bool password_command, const char* requested, const char* reply, uint32_t now) {\nchar requested_password[16] = {}; strcpy(requested_password, requested);\nBatchEntry e = {}; strcpy(e.reply, reply);\n" + post + "\npost_completed = true;\n}\n"
|
||||
return (ROOT / "test/fixtures/webconfig_password_batch.cpp").read_text().replace("@METHODS@", methods)
|
||||
|
||||
def test_committed_password_revokes_session_and_stale_queue(self):
|
||||
self.compile_run(self.batch_fixture())
|
||||
|
||||
def test_successful_commit_must_revoke_general_session(self):
|
||||
fixture = self.batch_fixture()
|
||||
old = fixture.replace(" clearSessionLocked();", "", 1)
|
||||
self.assertNotEqual(old, fixture)
|
||||
self.compile_run(old, expect_rejection=True)
|
||||
|
||||
def receipt_fixture(self):
|
||||
source = (ROOT / "src/helpers/esp32/WebConfigServer.cpp").read_text()
|
||||
config = extract_braced(source, "void WebConfigServer::handleConfigResult(")
|
||||
cli = extract_braced(source, "void WebConfigServer::handleCliResult(")
|
||||
config_loop = extract_braced(config, " for (int i = 0;")
|
||||
cli_loop = extract_braced(cli, " for (int i = from;")
|
||||
return (ROOT / "test/fixtures/webconfig_password_receipt.cpp").read_text().replace(
|
||||
"@CONFIG_LOOP@", config_loop).replace("@CLI_LOOP@", cli_loop)
|
||||
|
||||
def receipt_json_include(self):
|
||||
header = next((ROOT / ".pio/libdeps").glob("*/ArduinoJson/src/ArduinoJson.h"), None)
|
||||
self.assertIsNotNone(header, "native dependency headers are required")
|
||||
return (header.parent,)
|
||||
|
||||
def test_old_cookie_receipt_sanitizes_every_arbitrary_reply(self):
|
||||
self.compile_run(self.receipt_fixture(), includes=self.receipt_json_include())
|
||||
|
||||
def test_unfiltered_cli_receipt_fails_executable_regression(self):
|
||||
fixture = self.receipt_fixture()
|
||||
old = fixture.replace('r["reply"] = !receipt_only ?', 'r["reply"] = true ?', 1)
|
||||
self.assertNotEqual(old, fixture)
|
||||
self.compile_run(old, expect_rejection=True, includes=self.receipt_json_include())
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -41,7 +41,11 @@ class WebConfigRecoveryAuthTest(unittest.TestCase):
|
||||
def fixture(self):
|
||||
source = (ROOT / "src/helpers/esp32/WebConfigServer.cpp").read_text()
|
||||
methods = "\n".join(extract_braced(source, signature) for signature in (
|
||||
"static bool fixedTimeEquals(", "bool WebConfigServer::checkAuth(",
|
||||
"static bool fixedTimeEquals(", "static bool webConfigCookieMatches(",
|
||||
"void WebConfigServer::clearSessionLocked(",
|
||||
"bool WebConfigServer::checkAuthLocked(", "bool WebConfigServer::checkAuth(",
|
||||
"void WebConfigServer::beginBatchAuthorizationLocked(",
|
||||
"bool WebConfigServer::resultReceiptAllowedLocked(",
|
||||
"void WebConfigServer::handleLogin(", "void WebConfigServer::handleLogout("))
|
||||
# The actual config POST entry gate must reject a replacement password
|
||||
# before parsing or queueing it; exercise its exact production prefix.
|
||||
@@ -55,7 +59,8 @@ class WebConfigRecoveryAuthTest(unittest.TestCase):
|
||||
|
||||
def test_old_interface_only_fallback_fails_executable_regression(self):
|
||||
fixture = self.fixture()
|
||||
old = fixture.replace("if (_initial_setup || _wifi_ssid[0] == 0) { noteNetworkActivity(); return true; }", "return true;", 1)
|
||||
old = fixture.replace(" } else if (_mode != MODE_LAN) {",
|
||||
" return true;\n } else if (_mode != MODE_LAN) {", 1)
|
||||
self.assertNotEqual(old, fixture)
|
||||
self.compile_run(old, expect_rejection=True)
|
||||
|
||||
@@ -64,7 +69,7 @@ class WebConfigRecoveryAuthTest(unittest.TestCase):
|
||||
signature = "void WebConfigServer::handleLogout("
|
||||
start = fixture.index(signature, fixture.index("@METHODS@") if "@METHODS@" in fixture else 0)
|
||||
prefix, logout = fixture[:start], fixture[start:]
|
||||
gate = ' if (!checkAuth(req)) { req->send(401, "application/json", "{\\"error\\":\\"auth\\"}"); return; }\n'
|
||||
gate = ' if (!checkAuthLocked(req)) { req->send(401, "application/json", "{\\"error\\":\\"auth\\"}"); return; }\n'
|
||||
old = prefix + logout.replace(gate, "", 1)
|
||||
self.assertNotEqual(old, fixture)
|
||||
self.compile_run(old, expect_rejection=True)
|
||||
|
||||
@@ -3,6 +3,9 @@
|
||||
import gzip
|
||||
from pathlib import Path
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
|
||||
@@ -131,6 +134,78 @@ class WebConfigSecretMaskingTest(unittest.TestCase):
|
||||
self.assertIn("_mode != MODE_LAN", handler)
|
||||
self.assertIn("checkAuth(req)", handler)
|
||||
|
||||
def password_reply_fixture(self):
|
||||
batch = function_body(self.server, "void WebConfigServer::drainBatch(",
|
||||
"void WebConfigServer::attachRoutes(")
|
||||
start = batch.index(" _cb->execCommand(e.cmd, e.reply);")
|
||||
snippet = batch[start:batch.index(" // Keep the response snapshot", start)]
|
||||
return r'''
|
||||
#include <cassert>
|
||||
#include <cstring>
|
||||
#include <helpers/WebConfigBatch.h>
|
||||
struct Callback {
|
||||
const char* result;
|
||||
void execCommand(char*, char* reply) { strcpy(reply, result); }
|
||||
};
|
||||
struct Fixture {
|
||||
Callback cb{}, *_cb = &cb;
|
||||
bool _admin_pwd_set = false;
|
||||
struct { char cmd[160] = "password new-secret"; char reply[160] = {}; } e;
|
||||
bool run(const char* reply) {
|
||||
cb.result = reply;
|
||||
_admin_pwd_set = false;
|
||||
const bool admin_pwd = true;
|
||||
@SNIPPET@
|
||||
if (e.reply[0] == 0) strcpy(e.reply, "OK");
|
||||
return WebConfigBatch::nextAllOk(true, strncmp(e.reply, "OK", 2) == 0);
|
||||
}
|
||||
};
|
||||
int main() {
|
||||
Fixture fixture;
|
||||
assert(fixture.run("password now: new-secret"));
|
||||
assert(!strcmp(fixture.e.reply, "OK") && fixture._admin_pwd_set);
|
||||
assert(fixture.run("OK"));
|
||||
for (const char* error : {"Error: save unconfirmed; reboot may differ",
|
||||
"Error: unsupported command", ""}) {
|
||||
const bool saved = fixture.run(error);
|
||||
assert(!saved && !fixture._admin_pwd_set);
|
||||
assert(strncmp(fixture.e.reply, "Error:", 6) == 0);
|
||||
assert(WebConfigBatch::finishRebootAt(true, saved, 10) == 0);
|
||||
if (error[0]) assert(!strcmp(fixture.e.reply, error));
|
||||
}
|
||||
}
|
||||
'''.replace("@SNIPPET@", snippet)
|
||||
|
||||
def compile_password_reply_fixture(self, fixture, *, reject=False):
|
||||
compiler = shutil.which("g++") or shutil.which("clang++")
|
||||
if not compiler:
|
||||
self.skipTest("host C++ compiler unavailable")
|
||||
with tempfile.TemporaryDirectory(prefix="webconfig-password-reply-") as tmp:
|
||||
path = Path(tmp)
|
||||
source = path / "test.cpp"
|
||||
binary = path / "test"
|
||||
source.write_text(fixture)
|
||||
result = subprocess.run([compiler, "-std=c++17", "-include", "initializer_list",
|
||||
"-I", str(ROOT / "src"), str(source), "-o", str(binary)],
|
||||
capture_output=True, text=True, timeout=30)
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
result = subprocess.run([str(binary)], capture_output=True, text=True, timeout=10)
|
||||
if reject:
|
||||
self.assertNotEqual(result.returncode, 0)
|
||||
self.assertIn("Assertion", result.stderr)
|
||||
else:
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
|
||||
def test_password_form_masks_success_but_preserves_failed_save(self):
|
||||
self.compile_password_reply_fixture(self.password_reply_fixture())
|
||||
|
||||
def test_old_unconditional_password_mask_fails_regression(self):
|
||||
fixture = self.password_reply_fixture()
|
||||
old = fixture.replace("if (admin_pwd && WebConfigBatch::cliPasswordChanged(e.reply)) {",
|
||||
"if (admin_pwd) {", 1)
|
||||
self.assertNotEqual(old, fixture)
|
||||
self.compile_password_reply_fixture(old, reject=True)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -964,5 +964,180 @@ window.addEventListener("load",function(){
|
||||
self.assertIn('data-test-capture="clear"', dom)
|
||||
|
||||
|
||||
def test_password_save_receipt_reauth_preserves_results_and_never_resends(self):
|
||||
for case in ("saved", "partial", "failed", "legacy401", "login_retry", "reboot"):
|
||||
with self.subTest(case=case):
|
||||
status, config = self.setup_values()
|
||||
status.update(mode="setup" if case == "legacy401" else "lan")
|
||||
prelude = r'''<script>
|
||||
(function(){
|
||||
var kind=%s,status=%s,config=%s,submitted=null,posts=0,polls=0,logins=0;
|
||||
var authorized=true,wrongLogin=false,earlyRefresh=0,storageWrites=0,late=null;
|
||||
if(window.Storage)Storage.prototype.setItem=function(){storageWrites++;throw new Error("persistent password storage")};
|
||||
function response(value,code){code=code||200;return Promise.resolve({ok:code<400,status:code,json:function(){return Promise.resolve(value)}})}
|
||||
window.fetch=function(path,options){
|
||||
if(path==="/api/status")return response(status);
|
||||
if(path==="/api/late")return new Promise(function(resolve){late=resolve});
|
||||
if(path==="/api/config"&&options&&options.method==="POST"){
|
||||
posts++;submitted=JSON.parse(options.body);authorized=kind==="failed";
|
||||
return response({state:"pending",reqid:submitted.reqid},202);
|
||||
}
|
||||
if(path.indexOf("/api/config/result?")===0){
|
||||
polls++;
|
||||
if(path.indexOf("reqid="+submitted.reqid)<0)throw new Error("lost request id");
|
||||
if(kind==="legacy401"&&!authorized)return response({error:"auth"},401);
|
||||
if(polls===1)return response({state:"pending",reqid:submitted.reqid});
|
||||
return response({state:"done",reqid:submitted.reqid,all_ok:kind!=="partial"&&kind!=="failed",
|
||||
reboot:kind==="reboot",reauth_required:kind!=="failed"&&!authorized,results:[
|
||||
{key:"password",reply:kind==="failed"?"Error: save unconfirmed; reboot may differ":"OK"},
|
||||
{key:"name",reply:kind==="partial"?(authorized?"Error: rejected name":"Error"):"OK"}]});
|
||||
}
|
||||
if(path==="/api/login"){
|
||||
logins++;
|
||||
if(JSON.parse(options.body).password!=="new-admin")wrongLogin=true;
|
||||
if(kind==="login_retry"&&logins===1)return response({error:"try the current password"},401);
|
||||
authorized=true;return response({ok:true});
|
||||
}
|
||||
if(path==="/api/config"){
|
||||
if(submitted&&!authorized){earlyRefresh++;return response({error:"auth"},401)}
|
||||
return response(config);
|
||||
}
|
||||
if(path.indexOf("/api/scan")===0)return response({state:"done",networks:[]});
|
||||
return response({},404);
|
||||
};
|
||||
window.addEventListener("load",function(){
|
||||
setTimeout(function(){
|
||||
if(kind==="saved")api("/api/late").catch(function(){});
|
||||
st.dirty.password="new-admin";st.dirty.name="ChangedName";
|
||||
document.querySelectorAll('[data-k="password"],[data-cfm="password"]').forEach(function(el){el.value="new-admin"});
|
||||
saveChanges({reboot:kind==="partial"||kind==="failed"});
|
||||
},120);
|
||||
setTimeout(function(){
|
||||
if(late)late({ok:false,status:401,json:function(){return Promise.resolve({error:"auth"})}});
|
||||
if(kind==="login_retry"){
|
||||
document.body.setAttribute("data-test-intermediate-note",document.getElementById("login-note").textContent);
|
||||
document.body.setAttribute("data-test-intermediate-login",!document.getElementById("v-login").classList.contains("hide"));
|
||||
document.getElementById("login-pwd").value="new-admin";
|
||||
document.getElementById("login-form").dispatchEvent(new Event("submit",{bubbles:true,cancelable:true}));
|
||||
}
|
||||
},950);
|
||||
setTimeout(function(){
|
||||
var body=document.body,fields=document.querySelectorAll('[data-k="password"],[data-cfm="password"]');
|
||||
body.setAttribute("data-test-posts",posts);body.setAttribute("data-test-polls",polls);
|
||||
body.setAttribute("data-test-logins",logins);body.setAttribute("data-test-wrong-login",wrongLogin);
|
||||
body.setAttribute("data-test-early-refresh",earlyRefresh);body.setAttribute("data-test-storage-writes",storageWrites);
|
||||
body.setAttribute("data-test-login-visible",!document.getElementById("v-login").classList.contains("hide"));
|
||||
body.setAttribute("data-test-wizard-visible",!document.getElementById("v-wizard").classList.contains("hide"));
|
||||
body.setAttribute("data-test-password-cleared",Array.prototype.every.call(fields,function(el){return el.value===""}));
|
||||
body.setAttribute("data-test-password-dirty",Object.prototype.hasOwnProperty.call(st.dirty,"password"));
|
||||
body.setAttribute("data-test-name-dirty",Object.prototype.hasOwnProperty.call(st.dirty,"name"));
|
||||
body.setAttribute("data-test-name-error",!!document.querySelector('#v-app [data-k="name"]').closest(".f").querySelector(".chip.err"));
|
||||
body.setAttribute("data-test-name-detail",document.querySelector('#v-app [data-k="name"]').closest(".f").textContent.indexOf("rejected name")>=0);
|
||||
body.setAttribute("data-test-password-error",!!document.querySelector('#v-app [data-k="password"]').closest(".f").querySelector(".chip.err"));
|
||||
body.setAttribute("data-test-reboot",document.getElementById("reboot-ov").classList.contains("show"));
|
||||
body.setAttribute("data-test-login-note",document.getElementById("login-note").textContent);
|
||||
},1500);
|
||||
});
|
||||
})();
|
||||
</script>''' % (json.dumps(case), json.dumps(status), json.dumps(config))
|
||||
dom = self.run_page(prelude, virtual_time=1900)
|
||||
for attr, expected in (("posts", "1"), ("wrong-login", "false"),
|
||||
("early-refresh", "0"), ("storage-writes", "0"),
|
||||
("login-visible", "false")):
|
||||
self.assertIn(f'data-test-{attr}="{expected}"', dom)
|
||||
self.assertIn('data-test-logins="%s"' % (
|
||||
"0" if case == "failed" else "2" if case == "login_retry" else "1"), dom)
|
||||
self.assertIn('data-test-polls="%s"' % ("2" if case in ("failed", "legacy401") else "3"), dom)
|
||||
self.assertIn('data-test-reboot="%s"' % str(case == "reboot").lower(), dom)
|
||||
self.assertIn('data-test-password-cleared="%s"' % str(case != "failed").lower(), dom)
|
||||
self.assertIn('data-test-password-dirty="%s"' % str(case == "failed").lower(), dom)
|
||||
self.assertIn('data-test-wizard-visible="%s"' % str(case == "legacy401").lower(), dom)
|
||||
if case == "partial":
|
||||
self.assertIn('data-test-name-dirty="true"', dom)
|
||||
self.assertIn('data-test-name-error="true"', dom)
|
||||
self.assertIn('data-test-name-detail="true"', dom)
|
||||
self.assertIn("some settings were rejected. The node will not reboot.", dom)
|
||||
if case == "failed":
|
||||
self.assertIn('data-test-password-error="true"', dom)
|
||||
if case == "login_retry":
|
||||
self.assertIn('data-test-intermediate-login="true"', dom)
|
||||
self.assertIn('data-test-intermediate-note="Settings saved.', dom)
|
||||
|
||||
def test_cli_password_receipt_uses_last_success_and_stream_never_replays(self):
|
||||
for streaming in (False, True):
|
||||
with self.subTest(streaming=streaming):
|
||||
status, config = self.setup_values()
|
||||
status.update(mode="lan", cli=True, terminal_stream=streaming)
|
||||
prelude = r'''<script>
|
||||
(function(){
|
||||
var streaming=%s,status=%s,config=%s,submitted=null,posts=0,logins=0,wrongLogin=false,authorized=true,earlyRefresh=0;
|
||||
function response(value,code){code=code||200;return Promise.resolve({ok:code<400,status:code,json:function(){return Promise.resolve(value)}})}
|
||||
window.fetch=function(path,options){
|
||||
if(path==="/api/status")return response(status);
|
||||
if(path==="/api/config"){
|
||||
if(!authorized){earlyRefresh++;throw new Error("config read before reauthentication")}
|
||||
return response(config);
|
||||
}
|
||||
if((path==="/api/cli"||path==="/api/terminal")&&options&&options.method==="POST"){
|
||||
posts++;submitted=JSON.parse(options.body);authorized=false;return response({state:"pending"},202);
|
||||
}
|
||||
if(path.indexOf("/api/terminal?")===0)return response({error:"auth"},401);
|
||||
if(path.indexOf("/api/cli/result?")===0){
|
||||
var second=path.indexOf("from=2")>=0;
|
||||
return response({state:second?"done":"running",reqid:submitted.reqid,reauth_required:!authorized,
|
||||
all_ok:!second,reboot:false,reboot_withheld:second,results:second?[
|
||||
{ok:false,reply:authorized?"Error: rejected password":"Error"},{ok:true,reply:"OK - reboot queued"}]:[
|
||||
{ok:true,reply:"OK"},{ok:true,reply:"> ******** (sign in again)"}]});
|
||||
}
|
||||
if(path==="/api/login"){
|
||||
logins++;wrongLogin=wrongLogin||JSON.parse(options.body).password!=="first-new";
|
||||
authorized=true;return response({ok:true});
|
||||
}
|
||||
return response({},404);
|
||||
};
|
||||
window.addEventListener("load",function(){
|
||||
setTimeout(function(){cliRun(streaming?["password first-new","set freq 123"]:["password first-new","get wifi.pwd","password rejected-new","reboot"])},120);
|
||||
setTimeout(function(){
|
||||
var body=document.body;
|
||||
body.setAttribute("data-test-posts",posts);body.setAttribute("data-test-logins",logins);body.setAttribute("data-test-early-refresh",earlyRefresh);
|
||||
body.setAttribute("data-test-wrong-login",wrongLogin);body.setAttribute("data-test-busy",cli.busy);
|
||||
body.setAttribute("data-test-login-visible",!document.getElementById("v-login").classList.contains("hide"));
|
||||
body.setAttribute("data-test-reboot",document.getElementById("reboot-ov").classList.contains("show"));
|
||||
body.setAttribute("data-test-stream-reset",!cliStream.session&&!cliStream.job);
|
||||
body.setAttribute("data-test-withheld",document.getElementById("term-out").textContent.indexOf("Not rebooting")>=0);
|
||||
body.setAttribute("data-test-error-detail",document.getElementById("term-out").textContent.indexOf("rejected password")>=0);
|
||||
},1400);
|
||||
});
|
||||
})();
|
||||
</script>''' % (json.dumps(streaming), json.dumps(status), json.dumps(config))
|
||||
dom = self.run_page(prelude, virtual_time=1800)
|
||||
for attr, expected in (("posts", "1"), ("logins", "1"),
|
||||
("wrong-login", "false"), ("early-refresh", "0"), ("busy", "false"),
|
||||
("login-visible", "false"), ("reboot", "false")):
|
||||
self.assertIn(f'data-test-{attr}="{expected}"', dom)
|
||||
self.assertIn('data-test-stream-reset="true"', dom)
|
||||
if not streaming:
|
||||
self.assertIn('data-test-withheld="true"', dom)
|
||||
self.assertIn('data-test-error-detail="true"', dom)
|
||||
|
||||
def test_admin_password_validation_counts_utf8_bytes(self):
|
||||
status, config = self.setup_values()
|
||||
status.update(mode="lan")
|
||||
vectors = ["", "a" * 15, "a" * 16, "é" * 7 + "a", "é" * 8,
|
||||
"🙂" * 3 + "abc", "🙂" * 4, "line\nbreak", "line\rbreak", "\ud800"]
|
||||
prelude = r'''<script>
|
||||
(function(){
|
||||
var status=%s,config=%s,values=%s;
|
||||
window.fetch=function(path){var value=path==="/api/status"?status:path==="/api/config"?config:{};return Promise.resolve({ok:true,status:200,json:function(){return Promise.resolve(value)}})};
|
||||
window.addEventListener("load",function(){setTimeout(function(){
|
||||
var result=values.map(function(value){st.dirty.password=value;document.querySelectorAll('[data-cfm="password"]').forEach(function(el){el.value=value});return adminPwdError(false)?"invalid":"valid"});
|
||||
document.body.setAttribute("data-test-password-validation",result.join(","));
|
||||
},200)});
|
||||
})();
|
||||
</script>''' % (json.dumps(status), json.dumps(config), json.dumps(vectors))
|
||||
dom = self.run_page(prelude)
|
||||
self.assertIn('data-test-password-validation="valid,valid,invalid,valid,invalid,valid,invalid,invalid,invalid,invalid"', dom)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -24,6 +24,9 @@ SCENARIOS = (
|
||||
|
||||
STUBS = r'''
|
||||
#include <helpers/WirelessControl.h>
|
||||
#define MESH_MANAGEMENT_PASSWORD_HOST_TEST 1
|
||||
#include <helpers/esp32/ManagementPassword.h>
|
||||
static const bool published_password = mesh::esp32::managementPassword::publish("short");
|
||||
#include <cassert>
|
||||
#include <cstdint>
|
||||
#include <cstdio>
|
||||
@@ -40,11 +43,12 @@ constexpr int WL_CONNECTED = 3, WL_DISCONNECTED = 6, HTTP_GET = 0;
|
||||
enum wifi_mode_t { WIFI_OFF = 0, WIFI_STA = 1, WIFI_AP = 2 };
|
||||
constexpr wifi_mode_t WIFI_MODE_NULL = WIFI_OFF;
|
||||
constexpr wifi_mode_t WIFI_MODE_AP = WIFI_AP;
|
||||
constexpr int WIFI_IF_STA = 0, WIFI_IF_AP = 1, WIFI_AUTH_OPEN = 0;
|
||||
constexpr int WIFI_IF_STA = 0, WIFI_IF_AP = 1, WIFI_AUTH_OPEN = 0, WIFI_AUTH_WPA2_PSK = 3;
|
||||
constexpr int WIFI_SCAN_RUNNING = -1, AP_STARTED_BIT = 1;
|
||||
struct wifi_config_t {
|
||||
struct {
|
||||
uint8_t ssid[32] = {};
|
||||
uint8_t password[64] = {};
|
||||
uint8_t ssid_len = 0, ssid_hidden = 0, channel = 1;
|
||||
int authmode = WIFI_AUTH_OPEN;
|
||||
} ap;
|
||||
@@ -104,13 +108,15 @@ struct MockWiFi {
|
||||
configured_ap_ip = ip;
|
||||
return ap_config_ok;
|
||||
}
|
||||
bool softAP(const char* ssid, const char*, int channel = 1) {
|
||||
bool softAP(const char* ssid, const char* password, int channel = 1) {
|
||||
assert(channel == 1);
|
||||
++ap_start_calls;
|
||||
if (ap_start_ok) {
|
||||
sdk_initialized = sdk_started = sdk_ap_active = true;
|
||||
initialized_cache = started_cache = true;
|
||||
ap_ip = configured_ap_ip;
|
||||
ap_config.ap.authmode = password && password[0] ? WIFI_AUTH_WPA2_PSK : WIFI_AUTH_OPEN;
|
||||
if (password) strcpy(reinterpret_cast<char*>(ap_config.ap.password), password);
|
||||
ap_config.ap.ssid_len = strlen(ssid);
|
||||
memcpy(ap_config.ap.ssid, ssid, strlen(ssid));
|
||||
ap_config.ap.channel = channel;
|
||||
@@ -167,6 +173,8 @@ struct AsyncWebServer {
|
||||
};
|
||||
struct MockElegantOTA {
|
||||
void setID(const char*) {}
|
||||
void useManagementPassword() {}
|
||||
bool authenticateRequest(AsyncWebServerRequest*) { return true; }
|
||||
void begin(AsyncWebServer* server) { assert(server != nullptr); }
|
||||
bool setEnabled(bool) { return true; }
|
||||
} AsyncElegantOTA;
|
||||
@@ -182,6 +190,7 @@ public:
|
||||
~ESP32Board() { delete ota_server; }
|
||||
const char* getManufacturerName() const { return "test board"; }
|
||||
bool startOTAUpdate(const char* id, char reply[], bool force_ap);
|
||||
static bool rotateOTAAccessPoint(void*) { return true; }
|
||||
@OTA_RUNNING@
|
||||
} board;
|
||||
|
||||
@@ -408,7 +417,7 @@ class WifiOtaLifecycleTests(unittest.TestCase):
|
||||
def assert_healthy_ota(self, scenario, url, ap_starts):
|
||||
observation = self.observations[scenario]
|
||||
self.assertTrue(observation["first_started"] and observation["started"])
|
||||
network = "Join WiFi MeshCore-OTA" if url == "192.168.4.1" else "Use same WiFi/LAN"
|
||||
network = "Join WiFi MeshCore-OTA (OPEN compatibility; HTTP admin login)" if url == "192.168.4.1" else "Use same WiFi/LAN"
|
||||
self.assertEqual(observation["reply"], f"Started: http://{url}/update - WiFi on; {network}")
|
||||
self.assertEqual(observation["server_count"], 1)
|
||||
self.assertEqual(observation["server_begin_calls"], 1)
|
||||
|
||||
@@ -103,6 +103,7 @@ struct PortalImpl {
|
||||
void* callback_context = nullptr;
|
||||
volatile bool* active = nullptr;
|
||||
uint32_t close_ap_at = 0, recovery_interval_ms = 0;
|
||||
uint32_t ap_password_generation = 0, rekey_generation = 0, rekey_at = 0;
|
||||
char recovery_ssid[32] = {}, ap_name[33] = {};
|
||||
bool recovery_connecting = false;
|
||||
};
|
||||
@@ -168,6 +169,142 @@ int main() {
|
||||
with self.subTest(arduino_major=major):
|
||||
self.compile_and_run(fixture, f"-DESP_ARDUINO_VERSION_MAJOR={major}")
|
||||
|
||||
def test_management_ap_uses_current_password_and_rekeys_after_response_grace(self):
|
||||
source = (ROOT / "src/helpers/ESP32Board.cpp").read_text()
|
||||
lightweight, elegant = source.split("#elif defined(ADMIN_PASSWORD) && !defined(DISABLE_WIFI_OTA)", 1)
|
||||
for implementation, flags in ((lightweight, ["-DLIGHTWEIGHT_WIFI_OTA=1"]), (elegant, [])):
|
||||
with self.subTest(flags=flags):
|
||||
methods = "\n".join(extract_braced(implementation, signature) for signature in (
|
||||
"bool ESP32Board::startOTAUpdate(", "bool ESP32Board::stopOTAUpdate("))
|
||||
methods += "\n" + extract_braced(source, "bool ESP32Board::rotateOTAAccessPoint(")
|
||||
fixture = self.ap_fixture().split("int main() {", 1)[0]
|
||||
fixture = fixture.replace("static bool rotateOTAAccessPoint(void*) { return true; }",
|
||||
"static bool rotateOTAAccessPoint(void*);")
|
||||
fixture = fixture.replace("@METHODS@", methods) + r'''
|
||||
int main() {
|
||||
using namespace mesh::esp32::managementPassword;
|
||||
assert(publish("password"));
|
||||
ESP32Board board; char reply[160];
|
||||
assert(board.startOTAUpdate("fixture", reply, true));
|
||||
assert(strstr(reply, "(admin password)") && WiFi.connected);
|
||||
assert(WiFi.live_config.ap.authmode == WIFI_AUTH_WPA2_PSK);
|
||||
assert(!strcmp(reinterpret_cast<char*>(WiFi.live_config.ap.password), "password"));
|
||||
const unsigned starts = WiFi.starts;
|
||||
assert(board.startOTAUpdate("fixture", reply, true) && WiFi.starts == starts);
|
||||
// Reuse must check the actual driver password, not just authmode/SSID.
|
||||
strcpy(reinterpret_cast<char*>(WiFi.live_config.ap.password), "stale-password");
|
||||
assert(board.startOTAUpdate("fixture", reply, true) && WiFi.starts == starts + 1);
|
||||
Snapshot current; snapshot(current);
|
||||
{
|
||||
UploadGuard upload(current.generation); assert(upload.acquired());
|
||||
assert(!publish("new-password"));
|
||||
assert(!board.stopOTAUpdate(reply) && strstr(reply, "upload active"));
|
||||
}
|
||||
assert(publish("new-password"));
|
||||
const unsigned before = WiFi.starts;
|
||||
service(100); service(849); assert(WiFi.starts == before);
|
||||
service(850); assert(WiFi.starts == before + 1 && WiFi.connected);
|
||||
assert(!strcmp(reinterpret_cast<char*>(WiFi.live_config.ap.password), "new-password"));
|
||||
assert(publish("tiny")); service(1000); service(1750);
|
||||
assert(WiFi.live_config.ap.authmode == WIFI_AUTH_OPEN && WiFi.connected);
|
||||
assert(board.startOTAUpdate("fixture", reply, true) && strstr(reply, "OPEN compatibility"));
|
||||
assert(board.stopOTAUpdate(reply));
|
||||
assert(!WiFi.ap && WiFi.connected); // Preserve the station owner.
|
||||
}
|
||||
'''
|
||||
self.compile_and_run(fixture, *flags, "-DESP_ARDUINO_VERSION_MAJOR=2")
|
||||
|
||||
def test_ota_refuses_missing_or_empty_published_admin_before_network_effects(self):
|
||||
source = (ROOT / "src/helpers/ESP32Board.cpp").read_text()
|
||||
lightweight, elegant = source.split("#elif defined(ADMIN_PASSWORD) && !defined(DISABLE_WIFI_OTA)", 1)
|
||||
for implementation, flags in ((lightweight, ["-DLIGHTWEIGHT_WIFI_OTA=1"]), (elegant, [])):
|
||||
methods = "\n".join(extract_braced(implementation, signature) for signature in (
|
||||
"bool ESP32Board::startOTAUpdate(", "bool ESP32Board::stopOTAUpdate("))
|
||||
fixture = self.ap_fixture().split("int main() {", 1)[0]
|
||||
fixture = fixture.replace('static const bool published_password = mesh::esp32::managementPassword::publish("short");', "")
|
||||
fixture = fixture.replace("@METHODS@", methods) + r'''
|
||||
int main() {
|
||||
ESP32Board board; char reply[160];
|
||||
assert(!board.startOTAUpdate("fixture", reply, true));
|
||||
assert(!WiFi.starts && !server_starts && !board.inhibit_sleep);
|
||||
assert(mesh::esp32::managementPassword::initialize(""));
|
||||
assert(!board.startOTAUpdate("fixture", reply, true));
|
||||
assert(!WiFi.starts && !server_starts && !board.inhibit_sleep);
|
||||
}
|
||||
'''
|
||||
self.compile_and_run(fixture, *flags, "-DESP_ARDUINO_VERSION_MAJOR=2")
|
||||
|
||||
def test_elegant_root_and_log_do_not_bypass_management_auth(self):
|
||||
source = (ROOT / "src/helpers/ESP32Board.cpp").read_text()
|
||||
elegant = source.split("#elif defined(ADMIN_PASSWORD) && !defined(DISABLE_WIFI_OTA)", 1)[1]
|
||||
handlers = []
|
||||
for path in ("/", "/log"):
|
||||
marker = 'async_ota_host->on("' + path + '", HTTP_GET, [](AsyncWebServerRequest *request)'
|
||||
body = extract_braced(elegant, marker)
|
||||
body = body[body.index("{"):]
|
||||
handlers.append("void handle" + str(len(handlers)) + "(AsyncWebServerRequest* request) " + body)
|
||||
fixture = r'''
|
||||
#include <cassert>
|
||||
struct AsyncWebServerRequest {
|
||||
int replies = 0;
|
||||
template<class... Args> void send(Args...) { ++replies; }
|
||||
};
|
||||
struct Auth {
|
||||
bool permitted = false;
|
||||
int checks = 0;
|
||||
bool authenticateRequest(AsyncWebServerRequest*) { ++checks; return permitted; }
|
||||
} AsyncElegantOTA;
|
||||
static const char* home_buf = "fixture";
|
||||
int SPIFFS = 0;
|
||||
@HANDLERS@
|
||||
int main() {
|
||||
for (auto handler : {handle0, handle1}) {
|
||||
AsyncWebServerRequest request;
|
||||
AsyncElegantOTA.permitted = false; handler(&request); assert(request.replies == 0);
|
||||
AsyncElegantOTA.permitted = true; handler(&request); assert(request.replies == 1);
|
||||
}
|
||||
assert(AsyncElegantOTA.checks == 4);
|
||||
}
|
||||
'''
|
||||
fixture = '#include <initializer_list>\n' + fixture.replace("@HANDLERS@", "\n".join(handlers))
|
||||
self.compile_and_run(fixture)
|
||||
old = fixture.replace(" if (!AsyncElegantOTA.authenticateRequest(request)) return;", "")
|
||||
self.assertNotEqual(old, fixture)
|
||||
with self.assertRaises(AssertionError):
|
||||
self.compile_and_run(old)
|
||||
|
||||
def test_join_password_describes_live_ap_during_rekey_grace(self):
|
||||
fixture = self.ap_fixture().split("static unsigned server_starts", 1)[0] + r'''
|
||||
int main() {
|
||||
using namespace mesh::esp32::managementPassword;
|
||||
char password[65] = "old-value";
|
||||
assert(!mesh::wifi::accessPointJoinPassword("setup", password, sizeof(password)) && !password[0]);
|
||||
assert(publish("password"));
|
||||
assert(mesh::wifi::startManagementAccessPoint("setup", false));
|
||||
assert(mesh::wifi::accessPointJoinPassword("setup", password, sizeof(password)));
|
||||
assert(!strcmp(password, "password"));
|
||||
assert(publish("different"));
|
||||
assert(mesh::wifi::accessPointJoinPassword("setup", password, sizeof(password)));
|
||||
assert(!strcmp(password, "password")); // Registry leads actual AP during reply grace.
|
||||
assert(!mesh::wifi::accessPointJoinPassword("other", password, sizeof(password)) && !password[0]);
|
||||
assert(!mesh::wifi::accessPointJoinPassword("setup", password, 4) && !password[0]);
|
||||
assert(mesh::wifi::startManagementAccessPoint("setup", false));
|
||||
assert(mesh::wifi::accessPointJoinPassword("setup", password, sizeof(password)));
|
||||
assert(!strcmp(password, "different"));
|
||||
WiFi.live_config.ap.authmode = 99;
|
||||
assert(!mesh::wifi::accessPointJoinPassword("setup", password, sizeof(password)) && !password[0]);
|
||||
WiFi.live_config.ap.authmode = WIFI_AUTH_WPA2_PSK;
|
||||
memset(WiFi.live_config.ap.password, 'x', sizeof(WiFi.live_config.ap.password));
|
||||
assert(!mesh::wifi::accessPointJoinPassword("setup", password, sizeof(password)) && !password[0]);
|
||||
WiFi.live_config.ap.authmode = WIFI_AUTH_OPEN;
|
||||
assert(mesh::wifi::accessPointJoinPassword("setup", password, sizeof(password)) && !password[0]);
|
||||
WiFi.fault = Fault::ConfigRead;
|
||||
assert(!mesh::wifi::accessPointJoinPassword("setup", password, sizeof(password)) && !password[0]);
|
||||
}
|
||||
'''
|
||||
for major in (2, 3):
|
||||
self.compile_and_run(fixture, f"-DESP_ARDUINO_VERSION_MAJOR={major}")
|
||||
|
||||
def test_minimal_ota_never_reports_a_stale_sdk_hotspot_as_started(self):
|
||||
implementation = (ROOT / "src/helpers/ESP32Board.cpp").read_text().split(
|
||||
"#elif defined(ADMIN_PASSWORD) && !defined(DISABLE_WIFI_OTA)", 1)[0]
|
||||
@@ -194,6 +331,8 @@ int main() {
|
||||
start = cli.index(' } else if (memcmp(command, "start ota", 9)')
|
||||
end = cli.index(' } else if (memcmp(command, "clock", 5)', start)
|
||||
fixture = r'''
|
||||
#define MESH_MANAGEMENT_PASSWORD_HOST_TEST 1
|
||||
#include <helpers/esp32/ManagementPassword.h>
|
||||
#include <cassert>
|
||||
#include <cstdio>
|
||||
#include <cstring>
|
||||
@@ -237,6 +376,8 @@ int main() {
|
||||
start = cli.index(' } else if (memcmp(command, "start ota", 9)')
|
||||
end = cli.index(' } else if (memcmp(command, "clock", 5)', start)
|
||||
fixture = r'''
|
||||
#define MESH_MANAGEMENT_PASSWORD_HOST_TEST 1
|
||||
#include <helpers/esp32/ManagementPassword.h>
|
||||
#include <cassert>
|
||||
#include <cstdio>
|
||||
#include <cstring>
|
||||
@@ -276,6 +417,7 @@ struct Board {
|
||||
} board;
|
||||
struct Prefs {
|
||||
const char* node_name = "test";
|
||||
const char* password = "password";
|
||||
bool bridge_enabled = false, espnow_bridge_enabled = false;
|
||||
} prefs;
|
||||
struct CLI {
|
||||
@@ -344,6 +486,7 @@ int main() {
|
||||
methods = "\n".join(extract_braced(source, signature) for signature in (
|
||||
"void WebConfigServer::requestStop(", "bool WebConfigServer::stopForOTA(",
|
||||
"void WebConfigServer::finalizeTeardown(",
|
||||
"void WebConfigServer::clearSessionLocked(",
|
||||
"bool WebConfigServer::isNetworkForegroundActive(",
|
||||
"void WebConfigServer::refreshNetworkPowerHold("))
|
||||
fixture = (ROOT / "test/fixtures/webconfig_ota_handoff.cpp").read_text()
|
||||
@@ -354,6 +497,8 @@ int main() {
|
||||
start = cli.index(' } else if (memcmp(command, "start ota", 9)')
|
||||
end = cli.index(' } else if (memcmp(command, "clock", 5)', start)
|
||||
fixture = r'''
|
||||
#define MESH_MANAGEMENT_PASSWORD_HOST_TEST 1
|
||||
#include <helpers/esp32/ManagementPassword.h>
|
||||
#include <cassert>
|
||||
#include <cstdio>
|
||||
#include <cstring>
|
||||
@@ -396,6 +541,7 @@ struct Board {
|
||||
} board;
|
||||
struct Prefs {
|
||||
const char* node_name = "test";
|
||||
const char* password = "password";
|
||||
bool bridge_enabled = true, espnow_bridge_enabled = true;
|
||||
} prefs;
|
||||
struct CLI {
|
||||
@@ -476,6 +622,8 @@ int main() {
|
||||
|
||||
def test_background_bridge_retry_waits_until_ota_stops(self):
|
||||
fixture = r'''
|
||||
#define MESH_MANAGEMENT_PASSWORD_HOST_TEST 1
|
||||
#include <helpers/esp32/ManagementPassword.h>
|
||||
#include <cassert>
|
||||
#include <cstdint>
|
||||
uint32_t now = 0;
|
||||
@@ -541,6 +689,8 @@ int main() {
|
||||
source = (ROOT / "examples/companion_radio/MyMesh.cpp").read_text()
|
||||
command = extract_braced(source, 'if (strcmp(command, "start ota") == 0')
|
||||
fixture = r'''
|
||||
#define MESH_MANAGEMENT_PASSWORD_HOST_TEST 1
|
||||
#include <helpers/esp32/ManagementPassword.h>
|
||||
#include <cassert>
|
||||
#include <cstdio>
|
||||
#include <cstring>
|
||||
@@ -596,6 +746,8 @@ int main() {
|
||||
implementation = source[source.index("class LightweightOTAServer {"):]
|
||||
method = extract_braced(implementation, "bool end()")
|
||||
fixture = r'''
|
||||
#define MESH_MANAGEMENT_PASSWORD_HOST_TEST 1
|
||||
#include <helpers/esp32/ManagementPassword.h>
|
||||
#include <cassert>
|
||||
#include <cstdint>
|
||||
static unsigned delays = 0;
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Execute the legacy setup portal's production HTTP gate with fake clients."""
|
||||
from pathlib import Path
|
||||
import os
|
||||
import subprocess
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
from test_replay_reset_integration import extract_braced
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
|
||||
|
||||
class WiFiSetupAuthTest(unittest.TestCase):
|
||||
def test_setup_http_uses_current_admin_before_reading_or_saving_form(self):
|
||||
production = (ROOT / "src/helpers/WiFiSetupPortal.cpp").read_text()
|
||||
methods = "\n".join(extract_braced(production, signature) for signature in (
|
||||
"static bool readLine(", "static void sendResponse(", "static void handleClient("))
|
||||
source = r'''
|
||||
#include <helpers/esp32/ManagementPassword.h>
|
||||
#include <cassert>
|
||||
#include <cstdarg>
|
||||
#include <cstdio>
|
||||
#include <cstdlib>
|
||||
#include <cstring>
|
||||
#include <strings.h>
|
||||
#include <string>
|
||||
uint32_t now = 100;
|
||||
uint32_t millis() { return now; }
|
||||
void delay(unsigned value) { now += value; }
|
||||
struct WiFiClient {
|
||||
std::string input, output;
|
||||
size_t position = 0, bodies = 0;
|
||||
explicit WiFiClient(std::string text) : input(text) {}
|
||||
bool connected() { return true; }
|
||||
bool available() { return position < input.size(); }
|
||||
int read() { return available() ? static_cast<unsigned char>(input[position++]) : -1; }
|
||||
size_t readBytes(char* out, size_t count) {
|
||||
++bodies; size_t size = std::min(count, input.size() - position);
|
||||
memcpy(out, input.data() + position, size); position += size; return size;
|
||||
}
|
||||
void printf(const char* format, ...) {
|
||||
char buffer[2048]; va_list args; va_start(args, format);
|
||||
int size = vsnprintf(buffer, sizeof(buffer), format, args); va_end(args);
|
||||
assert(size >= 0 && size < int(sizeof(buffer))); output.append(buffer, size);
|
||||
}
|
||||
void write(const uint8_t* bytes, size_t count) { output.append(reinterpret_cast<const char*>(bytes), count); }
|
||||
};
|
||||
static const uint8_t MESH_HTML_WIFI_SETUP[] = {1, 2, 3};
|
||||
static const size_t MESH_HTML_WIFI_SETUP_LEN = sizeof(MESH_HTML_WIFI_SETUP);
|
||||
struct PortalImpl {};
|
||||
int saves = 0;
|
||||
void handleSave(PortalImpl*, WiFiClient&, const char* body) {
|
||||
assert(!strcmp(body, "ssid=test")); ++saves;
|
||||
// Association/persistence cannot overlap a password rotation or upload.
|
||||
using namespace mesh::esp32::managementPassword;
|
||||
Snapshot current; assert(snapshot(current));
|
||||
assert(!publish("unconfirmed"));
|
||||
UploadGuard upload(current.generation); assert(!upload.acquired());
|
||||
}
|
||||
@METHODS@
|
||||
WiFiClient request(const std::string& text, int status) {
|
||||
WiFiClient client(text); PortalImpl portal;
|
||||
handleClient(&portal, client);
|
||||
assert(client.output.find("HTTP/1.1 " + std::to_string(status) + " ") == 0);
|
||||
assert(client.output.find("Cache-Control: no-store") != std::string::npos);
|
||||
return client;
|
||||
}
|
||||
std::string get(const std::string& auth) { return "GET / HTTP/1.1\r\n" + auth + "\r\n"; }
|
||||
std::string post(const std::string& auth, const std::string& body = "ssid=test") {
|
||||
return "POST /save HTTP/1.1\r\nContent-Length: 9\r\n" + auth + "\r\n" + body;
|
||||
}
|
||||
int main() {
|
||||
using namespace mesh::esp32::managementPassword;
|
||||
const std::string old = "Authorization: Basic YWRtaW46cGFzc3dvcmQ=\r\n";
|
||||
const std::string next = "Authorization: Basic YWRtaW46bmV3LXBhc3N3b3Jk\r\n";
|
||||
request(get(old), 503); assert(publish("password"));
|
||||
auto prompt = request(get(""), 401);
|
||||
assert(prompt.output.find("WWW-Authenticate: Basic realm=\"MeshCore\"") != std::string::npos);
|
||||
assert(request(post(""), 401).bodies == 0 && saves == 0);
|
||||
assert(request(post("Authorization: Basic wrong\r\n"), 401).bodies == 0 && saves == 0);
|
||||
request(get(old), 200); request(post(old), 200); assert(saves == 1);
|
||||
assert(publish("new-password"));
|
||||
assert(request(post(old), 401).bodies == 0 && saves == 1);
|
||||
request(get(next), 200); request(post(next), 200); assert(saves == 2);
|
||||
assert(request(post(next + next), 401).bodies == 0 && saves == 2);
|
||||
request(get("Authorization: Basic " + std::string(100, 'A') + "\r\n"), 401);
|
||||
request(post(next, "short"), 408);
|
||||
{
|
||||
ChangeGuard change; assert(change.acquired());
|
||||
assert(request(post(next), 409).bodies == 0 && saves == 2);
|
||||
}
|
||||
request("POST /save HTTP/1.1\r\nContent-Length: 256\r\n" + next + "\r\n", 400);
|
||||
now += 30000;
|
||||
for (unsigned i = 0; i < 5; ++i) request(get("Authorization: Basic wrong\r\n"), 401);
|
||||
request(get(next), 429); now += 30000; request(get(next), 200);
|
||||
}
|
||||
'''.replace("@METHODS@", methods)
|
||||
# The production save helper writes its own success response.
|
||||
source = source.replace('assert(!strcmp(body, "ssid=test")); ++saves;',
|
||||
'assert(!strcmp(body, "ssid=test")); ++saves; client.printf("HTTP/1.1 200 OK\\r\\nCache-Control: no-store\\r\\n\\r\\n");')
|
||||
source = source.replace('void handleSave(PortalImpl*, WiFiClient&,', 'void handleSave(PortalImpl*, WiFiClient& client,')
|
||||
with tempfile.TemporaryDirectory(prefix="wifi-setup-auth-") as temporary:
|
||||
work = Path(temporary)
|
||||
(work / "test.cpp").write_text(source)
|
||||
result = subprocess.run([os.environ.get("CXX", "g++"), "-std=c++11", "-Wall", "-Wextra", "-Werror",
|
||||
"-fsanitize=address,undefined", "-fno-sanitize-recover=all", "-fno-pie", "-no-pie",
|
||||
"-DMESH_MANAGEMENT_PASSWORD_HOST_TEST=1", "-I", str(ROOT / "src"),
|
||||
str(work / "test.cpp"), "-o", str(work / "test")], capture_output=True, text=True, timeout=60)
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
result = subprocess.run([str(work / "test")], capture_output=True, text=True, timeout=15)
|
||||
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in new issue
Block a user