Use the node's admin password for the migration bridge AP
The Wi-Fi, LoRa first-stage and Partition Expander bridges serve an unauthenticated /update behind an AP whose password was fixed and published, so anyone in range during a migration could upload firmware while the private key is staged. Use the old node's saved admin password as the WPA2 passphrase, read from /com_prefs or /node_prefs at offset 56 as the firmware loads it. The operator already used that password to start the old updater. Fall back to the published password, with a serial warning, when the saved admin password is missing, unreadable, shorter than 8 characters or not printable ASCII, so no node becomes unreachable. The legacy filesystem is mounted without formatting and the password is never printed. Migration admission, flash sequencing, /update gating and the USB bridge are unchanged. Update both guides and the package README. Add a sanitizer-backed host test that compiles the bridge's actual Wi-Fi block against fake SPIFFS and WiFi: both boots, file precedence, WPA2 length and terminator bounds, printable characters, stock 1.10/1.11 file sizes, truncated fields, and mount, seek and partial-read failures. It fails 8 of 8 on the previous code and catches 27 of 27 code mutations. Verified 13 related host test files and the esp32_s3_4mb_partition_migrator build. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
0e72474f41
commit
09435b049d
6 files changed
+444
-10
No files matched your search
@@ -774,6 +774,7 @@ jobs:
|
||||
python3 -B test/test_firmware_capabilities.py
|
||||
MESHCORE_TEST_REAL_PIO_PATHS=1 python3 -B test/test_esp32_partition_migration_build_dir.py -v
|
||||
python3 -B test/test_esp32_partition_migration_usb.py -v
|
||||
python3 -B test/test_esp32_partition_migrator_ap_password.py -v
|
||||
python3 -B test/test_waveshare_partition_migration.py -v
|
||||
python3 -B test/test_usb_partition_bridge_set.py -v
|
||||
bash test/test_build_profiles.sh
|
||||
|
||||
@@ -284,11 +284,22 @@ Use `--board xiao-s3-wio` with the recipe above. Its Wi-Fi bridge is a
|
||||
legacy-slot application image; its Full image keeps the old repeater's exact
|
||||
LoRa OTA target ID. Upload `wifi-bridge.bin` from the ZIP through the legacy
|
||||
node's existing Wi-Fi browser updater.
|
||||
Then join `MeshCore-Migrate` (password `meshcore-migrate`) and wait for
|
||||
**Expanded layout ready**. Upload `full-application.bin` at `/update`.
|
||||
Then join `MeshCore-Migrate` and wait for **Expanded layout ready**. Its Wi-Fi
|
||||
password is the admin password saved on the old node, so only someone who can
|
||||
already administer the node can join and upload firmware. Upload
|
||||
`full-application.bin` at `/update`.
|
||||
Do not upload an `-merged.bin`: merged images include
|
||||
bootloader and table offsets for cable flashing, not browser OTA.
|
||||
|
||||
WPA2 needs a password of at least 8 printable ASCII characters. If the saved
|
||||
admin password is shorter, uses other characters, or was never saved (the node
|
||||
still runs on its built-in default), the bridge falls back to
|
||||
`meshcore-migrate`. That password is published here, so anyone nearby could
|
||||
then join and upload firmware. Before uploading the bridge, set an admin
|
||||
password of 8 to 15 characters with `password <new password>`. A LoRa
|
||||
first-stage bridge does not carry saved settings into the expanded layout, so
|
||||
its Wi-Fi recovery page after the table change normally uses the fallback.
|
||||
|
||||
`xiao_s3_partition_legacy_seed` is a disposable-hardware test image only. It
|
||||
models the legacy table and an identity file; it is not a deployable repeater.
|
||||
|
||||
|
||||
@@ -19,9 +19,10 @@ python3 -B scripts/build_esp32_partition_migration.py \
|
||||
|
||||
The ZIP contains the Wi-Fi bridge, LoRa bridge, and exact-target Full image.
|
||||
Upload its `wifi-bridge.bin` through the
|
||||
legacy browser Wi-Fi updater. Rejoin `MeshCore-Migrate` with password
|
||||
`meshcore-migrate`; when it reports **Expanded layout ready**, upload the
|
||||
ZIP's `full-application.bin` at `/update`.
|
||||
legacy browser Wi-Fi updater. Rejoin `MeshCore-Migrate` using the node's saved
|
||||
admin password as the Wi-Fi password (see the shared guide for the published
|
||||
fallback); when it reports **Expanded layout ready**, upload the ZIP's
|
||||
`full-application.bin` at `/update`.
|
||||
|
||||
Do not use an `-merged.bin` in either browser uploader. Merged images contain
|
||||
bootloader and table offsets for cable flashing. The bridge preserves the
|
||||
|
||||
@@ -33,7 +33,9 @@ namespace migration = mesh::esp32_partition_migration;
|
||||
namespace {
|
||||
|
||||
constexpr char kApSsid[] = "MeshCore-Migrate";
|
||||
constexpr char kApPassword[] = "meshcore-migrate";
|
||||
// Published in the docs, so it protects nothing. Used only when the node's own
|
||||
// admin password cannot serve as the WPA2 passphrase.
|
||||
constexpr char kFallbackApPassword[] = "meshcore-migrate";
|
||||
#ifndef MESHCORE_MIGRATION_DELAY_MS
|
||||
#define MESHCORE_MIGRATION_DELAY_MS 4500
|
||||
#endif
|
||||
@@ -1193,11 +1195,46 @@ void sendHome(AsyncWebServerRequest* request) {
|
||||
request->send(200, "text/html", page);
|
||||
}
|
||||
|
||||
bool ap_password_is_admin = false;
|
||||
|
||||
// Anyone who joins the bridge AP can upload firmware while the private key is
|
||||
// staged. The operator started this migration from the node's authenticated
|
||||
// terminal, so its admin password is a secret they already hold. Stock and
|
||||
// keymindCascade /com_prefs (and older /node_prefs) images all keep its
|
||||
// 16-byte field at offset 56; stock 1.10/1.11 images end before byte 290.
|
||||
bool loadAdminApPassword(char (&password)[16]) {
|
||||
constexpr size_t kPasswordOffset = 56;
|
||||
memset(password, 0, sizeof(password));
|
||||
// Never format: before migration this is the legacy filesystem.
|
||||
if (!SPIFFS.begin(false)) return false;
|
||||
const char* path = SPIFFS.exists("/com_prefs") ? "/com_prefs"
|
||||
: (SPIFFS.exists("/node_prefs") ? "/node_prefs" : nullptr);
|
||||
File prefs = path ? SPIFFS.open(path, "r") : File();
|
||||
// Byte 15 stays zero, matching the firmware's own terminator.
|
||||
bool valid = prefs && prefs.size() >= kPasswordOffset + sizeof(password)
|
||||
&& prefs.seek(kPasswordOffset)
|
||||
&& prefs.read(reinterpret_cast<uint8_t*>(password), sizeof(password) - 1)
|
||||
== sizeof(password) - 1;
|
||||
if (prefs) prefs.close();
|
||||
SPIFFS.end();
|
||||
// WPA2 needs 8-63 printable ASCII characters. Anything else falls back
|
||||
// rather than leaving the bridge unreachable.
|
||||
size_t length = 0;
|
||||
while (valid && password[length] != 0) {
|
||||
valid = password[length] >= 0x20 && password[length] <= 0x7E;
|
||||
++length;
|
||||
}
|
||||
return valid && length >= 8;
|
||||
}
|
||||
|
||||
void startServer() {
|
||||
// A legacy ESP-NOW repeater can leave the AP protocol mask in proprietary
|
||||
// LR mode. SoftAP then reports success but ordinary phones and laptops
|
||||
// cannot discover it. Reuse the normal WebConfig recipe: AP+STA mode,
|
||||
// an explicit interoperable protocol mask, and the project AP channel.
|
||||
char admin_password[16];
|
||||
ap_password_is_admin = loadAdminApPassword(admin_password);
|
||||
const char* ap_password = ap_password_is_admin ? admin_password : kFallbackApPassword;
|
||||
WiFi.mode(WIFI_AP_STA);
|
||||
WiFi.setAutoReconnect(false);
|
||||
WiFi.disconnect(false, true);
|
||||
@@ -1206,7 +1243,7 @@ void startServer() {
|
||||
const IPAddress address(192, 168, 4, 1);
|
||||
const IPAddress netmask(255, 255, 255, 0);
|
||||
if (!WiFi.softAPConfig(address, address, netmask)
|
||||
|| !WiFi.softAP(kApSsid, kApPassword, mesh::wifi::accessPointChannel())
|
||||
|| !WiFi.softAP(kApSsid, ap_password, mesh::wifi::accessPointChannel())
|
||||
|| mesh::wifi::applyAccessPointProtocolMask() != ESP_OK
|
||||
|| esp_wifi_set_protocol(WIFI_IF_STA, mesh::wifi::kProtocolMask) != ESP_OK
|
||||
|| esp_wifi_set_max_tx_power(78) != ESP_OK) {
|
||||
@@ -1221,6 +1258,9 @@ void startServer() {
|
||||
WiFi.softAPSSID().c_str(), WiFi.softAPIP().toString().c_str(),
|
||||
(unsigned)ap_config.ap.channel, (unsigned)ap_config.ap.ssid_hidden,
|
||||
max_tx_power / 4.0, (int)config_result, (int)power_result);
|
||||
if (!ap_password_is_admin)
|
||||
Serial.println("Warning: no saved admin password of 8-15 printable characters; "
|
||||
"anyone with the published fallback password can upload firmware");
|
||||
server.on("/", HTTP_GET, sendHome);
|
||||
server.on("/reboot", HTTP_GET, [](AsyncWebServerRequest* request) {
|
||||
if (!migration_complete) {
|
||||
@@ -1347,7 +1387,8 @@ void setup() {
|
||||
#else
|
||||
startServer();
|
||||
Serial.printf("%s. Join %s (password: %s), then open http://192.168.4.1/\n",
|
||||
status_text, kApSsid, kApPassword);
|
||||
status_text, kApSsid,
|
||||
ap_password_is_admin ? "this node's admin password" : kFallbackApPassword);
|
||||
#endif
|
||||
}
|
||||
|
||||
|
||||
@@ -506,8 +506,12 @@ installing the bridge; the bridge does not itself repeat normal traffic.
|
||||
1. On the old node, run `start ota ap` through its authenticated terminal.
|
||||
Join its `MeshCore-OTA` Wi-Fi and upload `wifi-bridge.bin` at the exact
|
||||
`Started:` URL returned by the command (the port can vary by old build).
|
||||
2. Keep power stable. Join `MeshCore-Migrate` (password `meshcore-migrate`),
|
||||
open `http://192.168.4.1/`, and wait for **Expanded layout ready**.
|
||||
2. Keep power stable. Join `MeshCore-Migrate` using the node's saved admin
|
||||
password as the Wi-Fi password, open `http://192.168.4.1/`, and wait for
|
||||
**Expanded layout ready**. If that password is shorter than 8 characters
|
||||
or was never saved, the bridge falls back to the published
|
||||
`meshcore-migrate`, which anyone nearby can use; set a longer admin
|
||||
password before step 1.
|
||||
3. At that page's `/update`, upload `full-application.bin`.
|
||||
"""
|
||||
usb_instructions = f"""## Host-managed USB route
|
||||
|
||||
@@ -0,0 +1,376 @@
|
||||
"""Execute the Wi-Fi migration bridge's AP credential choice on the host.
|
||||
|
||||
The bridge's AP gates an unauthenticated firmware uploader while the private
|
||||
key is staged. Compile the actual Wi-Fi-only block of the migrator against a
|
||||
fake SPIFFS and WiFi, and require the AP passphrase to be the node's saved
|
||||
admin password whenever that is a usable WPA2 passphrase.
|
||||
"""
|
||||
from pathlib import Path
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
from test_replay_reset_integration import extract_braced
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
SOURCE = ROOT / "examples/esp32_partition_migrator/main.cpp"
|
||||
DOCS = (
|
||||
ROOT / "docs/esp32_wifi_partition_migration.md",
|
||||
ROOT / "docs/heltec_v4_wifi_partition_migration.md",
|
||||
ROOT / "scripts/package_esp32_partition_migration.py",
|
||||
)
|
||||
|
||||
HARNESS = r'''
|
||||
#include <cassert>
|
||||
#include <cstdarg>
|
||||
#include <cstdint>
|
||||
#include <cstdio>
|
||||
#include <cstdlib>
|
||||
#include <cstring>
|
||||
#include <map>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
typedef int esp_err_t;
|
||||
constexpr esp_err_t ESP_OK = 0;
|
||||
enum { WIFI_AP_STA = 3, WIFI_IF_STA = 0, WIFI_IF_AP = 1, HTTP_GET = 1 };
|
||||
|
||||
struct String {
|
||||
std::string s;
|
||||
String() = default;
|
||||
String(const char* value) : s(value) {}
|
||||
void reserve(size_t bytes) { s.reserve(bytes); }
|
||||
String& operator+=(const char* value) { s += value; return *this; }
|
||||
String& operator+=(const String& value) { s += value.s; return *this; }
|
||||
const char* c_str() const { return s.c_str(); }
|
||||
};
|
||||
|
||||
struct SerialPort {
|
||||
std::string out;
|
||||
void println(const char* line) { out += line; out += '\n'; }
|
||||
__attribute__((format(printf, 2, 3))) void printf(const char* format, ...) {
|
||||
char line[512];
|
||||
va_list args;
|
||||
va_start(args, format);
|
||||
vsnprintf(line, sizeof(line), format, args);
|
||||
va_end(args);
|
||||
out += line;
|
||||
}
|
||||
} Serial;
|
||||
|
||||
struct IPAddress {
|
||||
uint8_t octets[4];
|
||||
IPAddress(uint8_t a, uint8_t b, uint8_t c, uint8_t d) : octets{a, b, c, d} {}
|
||||
String toString() const {
|
||||
char text[16];
|
||||
snprintf(text, sizeof(text), "%u.%u.%u.%u", octets[0], octets[1], octets[2], octets[3]);
|
||||
return String(text);
|
||||
}
|
||||
};
|
||||
|
||||
struct WiFiClass {
|
||||
std::string ssid;
|
||||
std::string password;
|
||||
bool open = false;
|
||||
int ap_calls = 0;
|
||||
bool mode(int) { return true; }
|
||||
void setAutoReconnect(bool) {}
|
||||
bool disconnect(bool, bool) { return true; }
|
||||
void setSleep(bool) {}
|
||||
bool softAPConfig(IPAddress, IPAddress, IPAddress) { return true; }
|
||||
// Arduino-ESP32 2.0.17 WiFiAP.cpp: a null or empty passphrase starts an
|
||||
// open AP; 1-7 characters fail; anything longer selects WPA2-PSK.
|
||||
bool softAP(const char* name, const char* passphrase, int) {
|
||||
++ap_calls;
|
||||
ssid = name;
|
||||
open = passphrase == nullptr || passphrase[0] == 0;
|
||||
password = passphrase ? passphrase : "";
|
||||
return open || strlen(passphrase) >= 8;
|
||||
}
|
||||
String softAPSSID() { return String(ssid.c_str()); }
|
||||
IPAddress softAPIP() { return IPAddress(192, 168, 4, 1); }
|
||||
} WiFi;
|
||||
|
||||
namespace mesh { namespace wifi {
|
||||
constexpr int kProtocolMask = 7;
|
||||
int accessPointChannel() { return 6; }
|
||||
esp_err_t applyAccessPointProtocolMask() { return ESP_OK; }
|
||||
} }
|
||||
esp_err_t esp_wifi_set_protocol(int, int) { return ESP_OK; }
|
||||
esp_err_t esp_wifi_set_max_tx_power(int) { return ESP_OK; }
|
||||
struct wifi_ap_config_t { uint8_t channel; uint8_t ssid_hidden; };
|
||||
struct wifi_config_t { wifi_ap_config_t ap; };
|
||||
esp_err_t esp_wifi_get_config(int, wifi_config_t* config) {
|
||||
config->ap.channel = 6;
|
||||
config->ap.ssid_hidden = 0;
|
||||
return ESP_OK;
|
||||
}
|
||||
esp_err_t esp_wifi_get_max_tx_power(int8_t* power) { *power = 78; return ESP_OK; }
|
||||
void delay(unsigned) {}
|
||||
|
||||
struct AsyncWebServerRequest {
|
||||
void send(int, const char*, const String&) {}
|
||||
void send(int, const char*, const char*) {}
|
||||
};
|
||||
struct AsyncWebServer {
|
||||
std::vector<std::string> routes;
|
||||
template <class Handler> void on(const char* path, int, Handler) { routes.push_back(path); }
|
||||
void begin() {}
|
||||
} server;
|
||||
struct ElegantOta {
|
||||
int begins = 0;
|
||||
void begin(AsyncWebServer*, const char* = "", const char* = "") { ++begins; }
|
||||
} AsyncElegantOTA;
|
||||
|
||||
struct File {
|
||||
std::vector<uint8_t>* data = nullptr;
|
||||
size_t position = 0;
|
||||
explicit operator bool() const { return data != nullptr; }
|
||||
size_t size() const { return data ? data->size() : 0; }
|
||||
bool seek(size_t offset);
|
||||
size_t read(uint8_t* buffer, size_t count);
|
||||
void close();
|
||||
};
|
||||
struct Filesystem {
|
||||
std::map<std::string, std::vector<uint8_t>> files;
|
||||
bool mountable = true, mounted = false, seek_fails = false, short_read = false;
|
||||
unsigned begins = 0, ends = 0, opens = 0, closes = 0;
|
||||
// Before migration this is the legacy filesystem: it must never be formatted.
|
||||
bool begin(bool format) { assert(!format); ++begins; mounted = mountable; return mounted; }
|
||||
void end() { ++ends; mounted = false; }
|
||||
bool exists(const char* path) { return mounted && files.count(path) != 0; }
|
||||
File open(const char* path, const char* mode) {
|
||||
assert(!strcmp(mode, "r"));
|
||||
File file;
|
||||
auto entry = files.find(path);
|
||||
if (mounted && entry != files.end()) { file.data = &entry->second; ++opens; }
|
||||
return file;
|
||||
}
|
||||
} SPIFFS;
|
||||
bool File::seek(size_t offset) {
|
||||
if (SPIFFS.seek_fails || offset > size()) return false;
|
||||
position = offset;
|
||||
return true;
|
||||
}
|
||||
size_t File::read(uint8_t* buffer, size_t count) {
|
||||
const size_t available = position < size() ? size() - position : 0;
|
||||
size_t bytes = count < available ? count : available;
|
||||
if (SPIFFS.short_read && bytes > 10) bytes = 10; // partial I/O after some data
|
||||
memcpy(buffer, data->data() + position, bytes);
|
||||
position += bytes;
|
||||
return bytes;
|
||||
}
|
||||
void File::close() { if (data) { ++SPIFFS.closes; data = nullptr; } }
|
||||
|
||||
char status_text[160] = "";
|
||||
bool migration_started = false;
|
||||
bool migration_complete = false;
|
||||
bool expanded_layout_ready = false;
|
||||
bool identity_ready = false;
|
||||
bool reboot_requested = false;
|
||||
|
||||
@CONSTANTS@
|
||||
@WIFI_BLOCK@
|
||||
void printJoinHint() {
|
||||
@JOIN_HINT@
|
||||
}
|
||||
|
||||
const char kFallback[] = @FALLBACK@;
|
||||
|
||||
// A /com_prefs image: printable bytes before the admin field (so a missed seek
|
||||
// would yield a plausible passphrase), the admin field at 56 and a different,
|
||||
// valid guest password at 88.
|
||||
std::vector<uint8_t> prefs(size_t bytes, const char* admin, size_t admin_bytes = 0) {
|
||||
std::vector<uint8_t> image(bytes < 104 ? 104 : bytes, 0);
|
||||
for (size_t i = 0; i < 56; ++i) image[i] = static_cast<uint8_t>('A' + i % 26);
|
||||
memcpy(&image[56], admin, admin_bytes ? admin_bytes : strnlen(admin, 16));
|
||||
memcpy(&image[88], "guest-password1", 15);
|
||||
image.resize(bytes);
|
||||
return image;
|
||||
}
|
||||
|
||||
void reset(bool expanded = false) {
|
||||
SPIFFS = Filesystem();
|
||||
WiFi = WiFiClass();
|
||||
Serial.out.clear();
|
||||
AsyncElegantOTA = ElegantOta();
|
||||
expanded_layout_ready = expanded;
|
||||
identity_ready = expanded;
|
||||
strcpy(status_text, expanded ? "Expanded layout ready" : "Legacy layout verified");
|
||||
}
|
||||
|
||||
void balancedMount() {
|
||||
assert(SPIFFS.begins == 1);
|
||||
assert(SPIFFS.ends == (SPIFFS.mountable ? 1U : 0U));
|
||||
assert(SPIFFS.opens == SPIFFS.closes);
|
||||
}
|
||||
|
||||
void expectAdmin(const char* expected) {
|
||||
startServer();
|
||||
printJoinHint();
|
||||
assert(WiFi.ap_calls == 1 && !WiFi.open);
|
||||
assert(WiFi.password == expected);
|
||||
assert(WiFi.password != kFallback);
|
||||
assert(strcmp(status_text, "Wi-Fi AP failed; restart the board and retry") != 0);
|
||||
// The secret is never written to the serial log; the hint names it instead.
|
||||
assert(Serial.out.find(expected) == std::string::npos);
|
||||
assert(Serial.out.find("password: this node's admin password") != std::string::npos);
|
||||
assert(Serial.out.find(kFallback) == std::string::npos);
|
||||
assert(Serial.out.find("Warning") == std::string::npos);
|
||||
assert(AsyncElegantOTA.begins == 1);
|
||||
balancedMount();
|
||||
}
|
||||
|
||||
void expectFallback() {
|
||||
startServer();
|
||||
printJoinHint();
|
||||
assert(WiFi.ap_calls == 1 && !WiFi.open);
|
||||
assert(WiFi.password == kFallback);
|
||||
assert(strcmp(status_text, "Wi-Fi AP failed; restart the board and retry") != 0);
|
||||
assert(Serial.out.find("Warning") != std::string::npos);
|
||||
assert(Serial.out.find(std::string("password: ") + kFallback) != std::string::npos);
|
||||
assert(AsyncElegantOTA.begins == 1);
|
||||
balancedMount();
|
||||
}
|
||||
|
||||
int main(int argc, char** argv) {
|
||||
assert(argc == 2);
|
||||
const int scenario = atoi(argv[1]);
|
||||
if (scenario == 0) {
|
||||
reset();
|
||||
SPIFFS.files["/com_prefs"] = prefs(290, "Correct-Horse9");
|
||||
expectAdmin("Correct-Horse9");
|
||||
// After the table change the Wi-Fi bridge has restored the same file.
|
||||
reset(true);
|
||||
SPIFFS.files["/com_prefs"] = prefs(1714, "Correct-Horse9");
|
||||
expectAdmin("Correct-Horse9");
|
||||
} else if (scenario == 1) {
|
||||
reset();
|
||||
SPIFFS.files["/node_prefs"] = prefs(290, "Node-prefs-pass");
|
||||
expectAdmin("Node-prefs-pass");
|
||||
// The firmware loads /com_prefs whenever it exists.
|
||||
reset();
|
||||
SPIFFS.files["/node_prefs"] = prefs(290, "Node-prefs-pass");
|
||||
SPIFFS.files["/com_prefs"] = prefs(290, "Com-prefs-pass");
|
||||
expectAdmin("Com-prefs-pass");
|
||||
} else if (scenario == 2) {
|
||||
reset(); SPIFFS.files["/com_prefs"] = prefs(290, "Seven77"); expectFallback();
|
||||
reset(); SPIFFS.files["/com_prefs"] = prefs(290, "Eight888"); expectAdmin("Eight888");
|
||||
reset(); SPIFFS.files["/com_prefs"] = prefs(290, "Fifteen-chars15");
|
||||
expectAdmin("Fifteen-chars15");
|
||||
// Byte 15 is always the terminator, as in the firmware's own loader.
|
||||
reset(); SPIFFS.files["/com_prefs"] = prefs(290, "Sixteen-chars-16", 16);
|
||||
expectAdmin("Sixteen-chars-1");
|
||||
reset(); SPIFFS.files["/com_prefs"] = prefs(290, ""); expectFallback();
|
||||
} else if (scenario == 3) {
|
||||
reset(); SPIFFS.files["/com_prefs"] = prefs(290, "With space 1"); expectAdmin("With space 1");
|
||||
reset(); SPIFFS.files["/com_prefs"] = prefs(290, "Tilde~~~~"); expectAdmin("Tilde~~~~");
|
||||
for (const char* unusable : {"Del\x7f-char-1", "Ctl\x1f-char-1", "Caf\xc3\xa9-pass1"}) {
|
||||
reset(); SPIFFS.files["/com_prefs"] = prefs(290, unusable); expectFallback();
|
||||
}
|
||||
} else if (scenario == 4) {
|
||||
// Stock 1.10/1.11 files end at 166/170 bytes; only the 16-byte field is needed.
|
||||
reset(); SPIFFS.files["/com_prefs"] = prefs(166, "Stock-1-10-pass"); expectAdmin("Stock-1-10-pass");
|
||||
reset(); SPIFFS.files["/com_prefs"] = prefs(72, "Just-fits-72"); expectAdmin("Just-fits-72");
|
||||
reset(); SPIFFS.files["/com_prefs"] = prefs(71, "Truncated-field"); expectFallback();
|
||||
reset(); SPIFFS.files["/com_prefs"] = prefs(60, "Truncated-field"); expectFallback();
|
||||
} else if (scenario == 5) {
|
||||
reset(); expectFallback(); // never saved: the built-in default is unknown here
|
||||
reset(); SPIFFS.mountable = false;
|
||||
SPIFFS.files["/com_prefs"] = prefs(290, "Correct-Horse9");
|
||||
expectFallback();
|
||||
} else if (scenario == 6) {
|
||||
reset(); SPIFFS.seek_fails = true;
|
||||
SPIFFS.files["/com_prefs"] = prefs(290, "Correct-Horse9");
|
||||
expectFallback();
|
||||
reset(); SPIFFS.short_read = true;
|
||||
SPIFFS.files["/com_prefs"] = prefs(290, "Correct-Horse9");
|
||||
expectFallback();
|
||||
} else {
|
||||
return 2;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
'''
|
||||
|
||||
|
||||
def wifi_block(source):
|
||||
end = source.index("#else\nbool verifiedUsbHandoff()")
|
||||
marker = "#if !defined(MESHCORE_MIGRATION_USB) || !MESHCORE_MIGRATION_USB\n"
|
||||
return source[source.rindex(marker, 0, end) + len(marker):end]
|
||||
|
||||
|
||||
class MigrationBridgeApPasswordTests(unittest.TestCase):
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
compiler = shutil.which("g++")
|
||||
if compiler is None:
|
||||
raise unittest.SkipTest("a host C++17 compiler is required")
|
||||
source = SOURCE.read_text(encoding="utf-8")
|
||||
constants = re.findall(r'^constexpr char k\w*Ap\w*\[\] = "[^"]*";$', source, re.M)
|
||||
fallback = re.search(r'^constexpr char k\w*ApPassword\[\] = ("[^"]*");$', source, re.M)
|
||||
setup = extract_braced(source, "void setup()")
|
||||
hint = re.search(r'Serial\.printf\("%s\. Join %s \(password: %s\)[\s\S]*?\);', setup)
|
||||
if not constants or fallback is None or hint is None:
|
||||
raise AssertionError("bridge AP constants or setup join hint not found")
|
||||
harness = (HARNESS.replace("@CONSTANTS@", "\n".join(constants))
|
||||
.replace("@WIFI_BLOCK@", wifi_block(source))
|
||||
.replace("@JOIN_HINT@", hint.group())
|
||||
.replace("@FALLBACK@", fallback.group(1)))
|
||||
cls.directory = tempfile.TemporaryDirectory(prefix="migration-ap-password-")
|
||||
path = Path(cls.directory.name) / "test.cpp"
|
||||
cls.binary = Path(cls.directory.name) / "test"
|
||||
path.write_text(harness, encoding="utf-8")
|
||||
result = subprocess.run([
|
||||
compiler, "-std=c++17", "-Wall", "-Wextra", "-Werror",
|
||||
"-fsanitize=address,undefined", "-fno-sanitize-recover=all", "-fno-pie", "-no-pie",
|
||||
str(path), "-o", str(cls.binary),
|
||||
], text=True, capture_output=True)
|
||||
if result.returncode:
|
||||
cls.directory.cleanup()
|
||||
raise AssertionError(result.stdout + result.stderr)
|
||||
|
||||
@classmethod
|
||||
def tearDownClass(cls):
|
||||
cls.directory.cleanup()
|
||||
|
||||
def run_scenario(self, scenario):
|
||||
result = subprocess.run([str(self.binary), str(scenario)], text=True, capture_output=True)
|
||||
self.assertEqual(0, result.returncode, result.stdout + result.stderr)
|
||||
|
||||
def test_saved_admin_password_protects_the_ap_before_and_after_expansion(self):
|
||||
self.run_scenario(0)
|
||||
|
||||
def test_firmware_prefs_file_precedence(self):
|
||||
self.run_scenario(1)
|
||||
|
||||
def test_wpa2_length_limits_and_firmware_terminator(self):
|
||||
self.run_scenario(2)
|
||||
|
||||
def test_only_printable_ascii_admin_passwords_are_used(self):
|
||||
self.run_scenario(3)
|
||||
|
||||
def test_older_stock_prefs_and_truncated_password_fields(self):
|
||||
self.run_scenario(4)
|
||||
|
||||
def test_missing_prefs_or_filesystem_falls_back_without_formatting(self):
|
||||
self.run_scenario(5)
|
||||
|
||||
def test_failed_seek_or_partial_read_falls_back(self):
|
||||
self.run_scenario(6)
|
||||
|
||||
def test_operator_instructions_name_the_admin_password(self):
|
||||
# Extra guard only: the instructions must not present the published
|
||||
# fallback as the bridge's normal Wi-Fi password.
|
||||
for path in DOCS:
|
||||
text = path.read_text(encoding="utf-8")
|
||||
self.assertIn("MeshCore-Migrate", text, path)
|
||||
self.assertIn("admin", text[text.index("MeshCore-Migrate"):][:200], path)
|
||||
self.assertNotRegex(text, r"\(password\s+`meshcore-migrate`\)", path)
|
||||
self.assertNotRegex(text, r"with password\s+`meshcore-migrate`", path)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in new issue
Block a user