276 lines
14 KiB
C++
276 lines
14 KiB
C++
#include <cassert>
|
|
#include <cstdio>
|
|
#include <cstring>
|
|
#include <map>
|
|
#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)) {}
|
|
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); }
|
|
};
|
|
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() {
|
|
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 { 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;
|
|
int localIP() const { return local_ip; }
|
|
uint32_t getRemoteAddress() const { return remote; }
|
|
};
|
|
struct Header { String cookies; const String& value() const { return cookies; } };
|
|
struct AsyncWebHeader {
|
|
std::string text;
|
|
const std::string& value() const { return text; }
|
|
};
|
|
struct AsyncWebServerResponse {
|
|
int status = 200; std::map<std::string, std::string> headers;
|
|
mutable AsyncWebHeader header_view;
|
|
bool addHeader(const char* key, const char* value) { headers[key]=value; return true; }
|
|
const AsyncWebHeader* getHeader(const char* key) const {
|
|
const auto found = headers.find(key);
|
|
if (found == headers.end()) return nullptr;
|
|
header_view.text = found->second;
|
|
return &header_view;
|
|
}
|
|
};
|
|
struct AsyncResponseStream : AsyncWebServerResponse {
|
|
std::string body;
|
|
size_t available() const { return body.size(); }
|
|
void setCode(int value) { status = value; }
|
|
size_t write(uint8_t value) { body.push_back(char(value)); return 1; }
|
|
size_t write(const uint8_t* bytes, size_t count) { body.append(reinterpret_cast<const char*>(bytes), count); return count; }
|
|
};
|
|
struct AsyncWebServerRequest {
|
|
Client socket; bool has_client=true;
|
|
Header header; std::string body, reply; void* _tempObject=nullptr;
|
|
int status=0; AsyncWebServerResponse response{};
|
|
Client* client() { return has_client ? &socket : nullptr; }
|
|
bool hasHeader(const char*) const { return !header.cookies.empty(); }
|
|
Header* getHeader(const char*) { return &header; }
|
|
void send(int code, const char* =nullptr, const char* =nullptr) { status=code; }
|
|
AsyncWebServerResponse* beginResponse(int code, const char*, const char*) { response.status=code; return &response; }
|
|
AsyncResponseStream* beginResponseStream(const char*, size_t = 0) { return new AsyncResponseStream; }
|
|
void send(AsyncResponseStream* value) { status=value->status; response=*value; reply=value->body; delete value; }
|
|
void send(AsyncWebServerResponse* value) { status=value->status; }
|
|
void password(const char* value) { body=std::string("{\"password\":\"")+value+"\"}"; _tempObject=body.data(); }
|
|
};
|
|
struct WebConfigServer {
|
|
enum { MODE_OFF, MODE_SETUP, MODE_LAN };
|
|
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, _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"); 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*);
|
|
};
|
|
@METHODS@
|
|
int main() {
|
|
WebConfigServer portal; AsyncWebServerRequest attacker;
|
|
attacker.body="{\"set\":{\"password\":\"attacker\",\"wifi.ssid\":\"EvilNet\"}}";
|
|
attacker._tempObject=attacker.body.data();
|
|
assert(!portal.checkAuth(&attacker));
|
|
assert(portal.network_activity==0);
|
|
portal.checkConfigPost(&attacker);
|
|
assert(attacker.status==401 && !portal.config_accepted);
|
|
attacker.password("wrong"); portal.handleLogin(&attacker);
|
|
assert(attacker.status==401 && portal._session_token[0]==0);
|
|
assert(portal.network_activity==0);
|
|
AsyncWebServerRequest admin; admin.password("existing-secret");
|
|
portal.handleLogin(&admin); assert(admin.status==200);
|
|
assert(portal.network_activity>0);
|
|
assert(strlen(portal._session_token)==32);
|
|
assert(admin.response.headers["Set-Cookie"].find("HttpOnly; SameSite=Lax")!=std::string::npos);
|
|
admin.header.cookies=std::string("wcs=")+portal._session_token;
|
|
assert(portal.checkAuth(&admin)); portal.checkConfigPost(&admin);
|
|
assert(portal.config_accepted);
|
|
std::string valid_token=portal._session_token;
|
|
portal.handleLogout(&attacker);
|
|
assert(attacker.status==401 && valid_token==portal._session_token);
|
|
admin.socket.local_ip=9;
|
|
assert(!portal.checkAuth(&admin));
|
|
admin.password("existing-secret"); portal.handleLogin(&admin);
|
|
assert(admin.status==401); // recovery must not authorize a recovered STA/LAN
|
|
admin.socket.local_ip=4;
|
|
admin.has_client=false; assert(!portal.checkAuth(&admin)); admin.has_client=true;
|
|
now_ms+=WEBCONFIG_SESSION_TTL_MS+1; assert(!portal.checkAuth(&admin));
|
|
admin.password("existing-secret"); portal.handleLogin(&admin); assert(admin.status==200);
|
|
admin.header.cookies=std::string("wcs=")+portal._session_token;
|
|
portal.handleLogout(&admin); assert(admin.status==200 && portal._session_token[0]==0);
|
|
assert(!portal.checkAuth(&admin));
|
|
portal._mode=WebConfigServer::MODE_LAN; admin.socket.local_ip=9;
|
|
admin.password("existing-secret"); portal.handleLogin(&admin); assert(admin.status==200);
|
|
admin.header.cookies=std::string("wcs=")+portal._session_token; assert(portal.checkAuth(&admin));
|
|
portal._mode=WebConfigServer::MODE_OFF; assert(!portal.checkAuth(&admin));
|
|
portal.handleLogin(&admin); assert(admin.status==503);
|
|
// 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;
|
|
// 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.
|
|
for (int mode : {WebConfigServer::MODE_SETUP, WebConfigServer::MODE_LAN}) {
|
|
WebConfigServer active; active._mode=mode;
|
|
AsyncWebServerRequest owner; owner.socket.remote=100;
|
|
owner.password("existing-secret"); active.handleLogin(&owner);
|
|
assert(owner.status==200);
|
|
const std::string token=active._session_token;
|
|
owner.header.cookies=std::string("wcs=")+token;
|
|
for (uint32_t address = 1; address <= 6; address++) {
|
|
AsyncWebServerRequest wrong; wrong.socket.remote=address;
|
|
wrong.password("wrong"); active.handleLogin(&wrong); assert(wrong.status==401);
|
|
}
|
|
AsyncWebServerRequest login; login.socket.remote=200;
|
|
login.password("existing-secret"); active.handleLogin(&login);
|
|
assert(login.status==429 && token==active._session_token);
|
|
assert(active.checkAuth(&owner));
|
|
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");
|
|
}
|