From a99c3c06802e3dd757a1a682bd134c99e73007ff Mon Sep 17 00:00:00 2001 From: Ben Meadors Date: Thu, 16 Jul 2026 12:46:40 -0500 Subject: [PATCH] fix(lockdown): re-lock serial console admin auth on USB link drop On lockdown builds (MESHTASTIC_PHONEAPI_ACCESS_CONTROL, nRF52) the SerialConsole is a process-lifetime singleton, so the per-connection admin-auth slot keyed by its inherited PhoneAPI* is reused for every USB/serial client for the whole boot. An operator's admin unlock stayed latched across serial client swaps: an attacker plugging into the USB/serial port before the prior session's 15-minute inactivity timeout inherited admin authorization -- the serial analog of the BLE stale-session reuse bug closed by resetting state in onConnect()/onDisconnect(). Sample the USB-CDC host link (DTR/mount) state each runOnce(); on the link-drop edge call close(), which frees the auth slot and resets PhoneAPI state so whoever connects next re-locks via handleStartConfig()'s !isConnected() branch on their first want_config -- the same physical-link boundary BLE enforces in onConnect(). On the nRF52 TinyUSB (Adafruit) core, (bool)Port == tud_cdc_n_connected(), which goes false on cable unplug or host port-close. Console transports without a real DTR line fall back to the existing inactivity timeout, no worse than before. Entirely nRF52-lockdown-gated; non-lockdown builds are byte-identical. --- src/SerialConsole.cpp | 34 ++++++++++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/src/SerialConsole.cpp b/src/SerialConsole.cpp index d6810e123be..b3224221283 100644 --- a/src/SerialConsole.cpp +++ b/src/SerialConsole.cpp @@ -30,6 +30,16 @@ SerialConsole *console; +#ifdef MESHTASTIC_PHONEAPI_ACCESS_CONTROL +// Last-seen USB-CDC host link (DTR/mount) state, sampled each runOnce() so a +// physical unplug/replug re-locks the per-connection admin auth (see runOnce()). +// Kept at file scope rather than as a member both because there is exactly one +// console singleton and because adding per-instance members to the PhoneAPI +// hierarchy has historically perturbed nRF52 USB-CDC enumeration (see PhoneAPI.h). +// Only compiled on lockdown (nRF52) builds. +static bool s_serialLinkUp = false; +#endif + /// Create the shared serial console once and register receive wakeups. void consoleInit() { @@ -88,6 +98,30 @@ SerialConsole::SerialConsole() : StreamAPI(&Port), RedirectablePrint(&Port), con /// Service one serial API iteration and select the next polling interval. int32_t SerialConsole::runOnce() { +#ifdef MESHTASTIC_PHONEAPI_ACCESS_CONTROL + // Lockdown (nRF52) builds only. The SerialConsole is a process-lifetime + // singleton, so its inherited PhoneAPI object - and therefore its entry in + // the per-connection admin-auth slot table (keyed by PhoneAPI*) - is reused + // for every USB/serial client for the whole boot. Nothing re-locks that slot + // when the operator unplugs and a different client plugs in before the + // 15-minute inactivity timeout fires, so a fresh client would inherit the + // prior operator's admin authorization. Re-lock when the physical USB-CDC link + // drops - the serial analog of the BLE onDisconnect() -> close() session reset. + // + // On the nRF52 TinyUSB (Adafruit) core, (bool)Port == tud_cdc_n_connected(): + // it goes false on cable unplug or host port-close (DTR de-assert). close() + // frees the auth slot and resets PhoneAPI state, so whoever connects next + // re-locks via handleStartConfig()'s !isConnected() branch on their first + // want_config - the same physical-link boundary BLE enforces in onConnect(). + // Console transports without a real DTR line (e.g. a UART USER_DEBUG_PORT) hold + // this constant, so no edge fires and we fall back to the existing inactivity + // timeout - no worse than the pre-fix behavior. + const bool linkUp = static_cast(Port); + if (s_serialLinkUp && !linkUp) + close(); + s_serialLinkUp = linkUp; +#endif + #ifdef HELTEC_MESH_SOLAR // After enabling the mesh solar serial port module configuration, command processing is handled by the serial port module. if (moduleConfig.serial.enabled && moduleConfig.serial.override_console_serial_port &&