diff --git a/.github/workflows/native-tests.yml b/.github/workflows/native-tests.yml new file mode 100644 index 0000000..fe797e9 --- /dev/null +++ b/.github/workflows/native-tests.yml @@ -0,0 +1,34 @@ +# CI — host-native unit tests for the pure, Arduino-free gate logic. +# +# These compile + RUN on a normal host toolchain (no ESP32 cross-compile, no board), so the +# safety-critical DECISION logic is verified on every push — independent of the WIP firmware +# link stage (see build.yml / docs/CI-STATUS.md). Today this locks in the W1 wipe-misfire fix: +# ONLY a clean `sm_wipe` may ever reach the authenticated wipe flow. +name: native-tests + +on: + push: + branches: [ master, main ] + pull_request: + workflow_dispatch: + +permissions: + contents: read + +jobs: + sm-command: + name: sm-command classifier (host g++) + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Compile + run the sm-command safety test + shell: bash + run: | + set -euo pipefail + g++ -std=c++17 -Wall -Wextra -Werror \ + -I firmware/bootgate \ + firmware/test_harness/native/test_sm_command.cpp -o /tmp/test_sm_command + /tmp/test_sm_command + echo "sm-command safety invariant holds: only a clean sm_wipe -> WipeRequest" diff --git a/firmware/bootgate/GateInput_serial.cpp b/firmware/bootgate/GateInput_serial.cpp index 8dd6682..26bfa1a 100644 --- a/firmware/bootgate/GateInput_serial.cpp +++ b/firmware/bootgate/GateInput_serial.cpp @@ -22,6 +22,7 @@ #ifdef GATE_INPUT_SERIAL #include "GateInput.h" +#include "SmCommand.h" #include #include @@ -237,50 +238,46 @@ void sendInfo(const GateConfig& cfg) { Serial.println(F("\"}")); } -// Process a dashboard command line. Returns true if the line was a recognized SM_ command -// (handled here); false if it should be passed to the normal password extraction flow. -bool processCommand(const char* trimmed, const GateConfig& cfg) { - if (startsWithCmd(trimmed, "sm_status", 9) && - (trimmed[9] == '\0' || trimmed[9] == ' ')) { - sendStatus(cfg); - return true; +// Process a dashboard command line. Classification is delegated to the pure classifySmCommand() +// (SmCommand.h) so the decision is unit-testable and cannot be conflated with the Serial I/O; this +// function performs the side effects and returns a TRI-STATE (SmDispatch) so the caller can tell a +// deliberate wipe from an unrecognized line. SPEC §6.1: only a clean `sm_wipe` is a WipeRequest; an +// unrecognized `sm_` line is Unknown (the caller rejects + re-prompts, never wipes, never an attempt). +SmDispatch processCommand(const char* trimmed, const GateConfig& cfg) { + switch (classifySmCommand(trimmed)) { + case SmCommand::Status: + sendStatus(cfg); + return SmDispatch::Handled; + case SmCommand::Info: + sendInfo(cfg); + return SmDispatch::Handled; + case SmCommand::Arm: + // ARM requires re-provisioning from the host (the armed flag is in the guardcfg NVS image). + // We cannot modify it at runtime without the host provisioner. Report this. + Serial.println(F("SM>{\"cmd\":\"ARM\",\"error\":\"arming requires re-provisioning from host " + "(provision.py --armed 1). Cannot modify guardcfg NVS at runtime.\"}")); + return SmDispatch::Handled; + case SmCommand::Disarm: + // DISARM also requires re-provisioning. The armed flag is baked into the NVS image. + Serial.println(F("SM>{\"cmd\":\"DISARM\",\"error\":\"disarming requires re-provisioning from host " + "(provision.py --armed 0). Cannot modify guardcfg NVS at runtime.\"}")); + return SmDispatch::Handled; + case SmCommand::SetPassword: + // Password change requires re-provisioning (new salt + hash). + Serial.println(F("SM>{\"cmd\":\"SET_PASSWORD\",\"error\":\"password change requires re-provisioning " + "from host (provision.py). Cannot modify guardcfg NVS at runtime.\"}")); + return SmDispatch::Handled; + case SmCommand::Wipe: + // The deliberate SM_WIPE maps to the existing authenticated `wipe` flow. We announce the + // redirect and hand off; the caller prompts for the password and only a correct one wipes. + Serial.println(F("SM>{\"cmd\":\"WIPE\",\"status\":\"redirecting to authenticated wipe flow\"}")); + return SmDispatch::WipeRequest; + case SmCommand::Unknown: + default: + // Typo / unknown-or-future command / mangled keyword. The caller rejects it — NEVER the wipe + // path, NEVER counted as a password attempt (SPEC §6.1). + return SmDispatch::Unknown; } - if (startsWithCmd(trimmed, "sm_info", 7) && - (trimmed[7] == '\0' || trimmed[7] == ' ')) { - sendInfo(cfg); - return true; - } - if (startsWithCmd(trimmed, "sm_arm", 6) && - (trimmed[6] == '\0' || trimmed[6] == ' ')) { - // ARM requires re-provisioning from the host (the armed flag is in the guardcfg NVS image). - // We cannot modify it at runtime without the host provisioner. Report this. - Serial.println(F("SM>{\"cmd\":\"ARM\",\"error\":\"arming requires re-provisioning from host " - "(provision.py --armed 1). Cannot modify guardcfg NVS at runtime.\"}")); - return true; - } - if (startsWithCmd(trimmed, "sm_disarm", 9) && - (trimmed[9] == '\0' || trimmed[9] == ' ')) { - // DISARM also requires re-provisioning. The armed flag is baked into the NVS image. - Serial.println(F("SM>{\"cmd\":\"DISARM\",\"error\":\"disarming requires re-provisioning from host " - "(provision.py --armed 0). Cannot modify guardcfg NVS at runtime.\"}")); - return true; - } - if (startsWithCmd(trimmed, "sm_set_password", 15) && - (trimmed[15] == '\0' || trimmed[15] == ' ')) { - // Password change requires re-provisioning (new salt + hash). - Serial.println(F("SM>{\"cmd\":\"SET_PASSWORD\",\"error\":\"password change requires re-provisioning " - "from host (provision.py). Cannot modify guardcfg NVS at runtime.\"}")); - return true; - } - if (startsWithCmd(trimmed, "sm_wipe", 7) && - (trimmed[7] == '\0' || trimmed[7] == ' ')) { - // SM_WIPE is mapped to the existing `wipe` command flow — password-authenticated. - Serial.println(F("SM>{\"cmd\":\"WIPE\",\"status\":\"redirecting to authenticated wipe flow\"}")); - // Fall through to the normal wipe handler by returning false and letting the caller - // see "wipe" as the command. We rewrite the intent so the standard wipe path handles it. - return false; // caller will re-process as "wipe" - } - return false; // not a dashboard command } } // namespace dashboard @@ -299,18 +296,26 @@ InputResult Input::getPassword(const GateConfig& cfg) { const char* p = line; while (*p == ' ' || *p == '\t') ++p; - // Dashboard integration: check for SM_ commands first. These are handled without counting as - // password attempts and return got=false so BootGate re-prompts. SM_WIPE falls through to the - // normal wipe handler below. + // Dashboard integration: check for SM_ commands first. These never count as password attempts and + // return got=false so BootGate re-prompts. The tri-state dispatch is the W1 misfire fix: ONLY a + // clean SM_WIPE is routed into the wipe flow; an unrecognized SM_ line is rejected and re-prompted + // (SPEC §6.1) instead of being silently dropped into the authenticated wipe prompt. if (startsWithCmd(p, "sm_", 3)) { - bool handled = dashboard::processCommand(p, cfg); - if (handled) { - secureZero(line, sizeof(line)); - return r; // got=false — re-prompt, not a password attempt + switch (dashboard::processCommand(p, cfg)) { + case dashboard::SmDispatch::Handled: + secureZero(line, sizeof(line)); + return r; // got=false — re-prompt, not a password attempt + case dashboard::SmDispatch::WipeRequest: + p = "wipe"; // deliberate SM_WIPE only — fall through to the authenticated wipe handler + break; + case dashboard::SmDispatch::Unknown: + default: + // Typo / unknown / mangled sm_ line: reject and re-prompt. Never the wipe path, never an + // attempt (SPEC §6.1 — recognized-but-deferred error reply). + Serial.println(F("SM>{\"error\":\"unknown command\"}")); + secureZero(line, sizeof(line)); + return r; // got=false — re-prompt, not a password attempt, no wipe } - // SM_WIPE was not fully handled — it falls through to the standard wipe flow below. - // Rewrite p to point to "wipe" so the existing wipe handler picks it up. - p = "wipe"; } // `wipe` -> AUTHENTICATED host-assisted self-destruct (SPEC §6). We must NOT set wipeRequest diff --git a/firmware/bootgate/SmCommand.h b/firmware/bootgate/SmCommand.h new file mode 100644 index 0000000..43039b6 --- /dev/null +++ b/firmware/bootgate/SmCommand.h @@ -0,0 +1,85 @@ +// SmCommand.h — pure, dependency-free classification of a dashboard "sm_" serial command. +// +// This is the DECISION half of the Cyber-Controller dashboard protocol, split out from the Serial +// I/O in GateInput_serial.cpp so it can be unit-tested and statically compiled on a host with no +// Arduino/Serial/NVS dependencies. It exists to fix the W1 wipe-misfire (audit +// DEADMANS-AUDIT-2026-07-12 §GateInput_serial.cpp:313): the old caller overloaded a `bool` return +// so that BOTH a deliberate `sm_wipe` AND any unrecognized `sm_` line returned `false`, and then +// unconditionally routed `false` into the authenticated wipe prompt. A typo (`sm_reboot`), an +// unknown/future command, or a mangled keyword therefore dropped the operator into the wipe flow; +// entering the correct password there fired an irreversible wipe. +// +// SPEC §6.1: an unrecognized SM_ command is a deferred error reply — NEVER a password attempt, and +// NEVER the wipe path. The tri-state below makes that impossible to conflate: ONLY a clean `sm_wipe` +// maps to WipeRequest; everything else is Handled (in place) or Unknown (rejected + re-prompt). +#ifndef SUICIDE_SM_COMMAND_H +#define SUICIDE_SM_COMMAND_H + +#include + +namespace suicide { +namespace dashboard { + +// The recognized dashboard commands, plus the catch-all Unknown. +enum class SmCommand { + Status, // sm_status -> emit status JSON (handled in place) + Info, // sm_info -> emit info JSON (handled in place) + Arm, // sm_arm -> "re-provision required" reply (handled in place) + Disarm, // sm_disarm -> "re-provision required" reply (handled in place) + SetPassword, // sm_set_password -> "re-provision required" reply (handled in place) + Wipe, // sm_wipe -> route to the AUTHENTICATED wipe flow + Unknown, // anything else sm_-prefixed -> reject + re-prompt, NEVER wipe, NEVER an attempt +}; + +// What the getPassword() caller must do with a classified command. +enum class SmDispatch { + Handled, // fully processed here -> re-prompt, not a password attempt + WipeRequest, // the deliberate sm_wipe -> hand off to the authenticated wipe flow + Unknown, // unrecognized sm_ line -> emit an error, re-prompt, never a wipe / never an attempt +}; + +// Case-insensitive ASCII prefix match (dashboard commands are lowercase ASCII). Mirrors the +// startsWithCmd() helper in GateInput_serial.cpp; kept here so this header stays self-contained. +inline bool smPrefix(const char* line, const char* cmd, size_t cmdLen) { + for (size_t i = 0; i < cmdLen; ++i) { + char c = line[i]; + if (c >= 'A' && c <= 'Z') c = static_cast(c - 'A' + 'a'); + if (c != cmd[i]) return false; + } + return true; +} + +// A recognized command must be the WHOLE token: the keyword followed by end-of-string or a space. +// A trailing tab or any extra byte (`sm_wipex`, `sm_wipe\t`) is therefore NOT a match and falls to +// Unknown -- the safe choice for a misfire fix: a mangled `sm_wipe` must never reach the wipe flow. +// (Preserves the exact '\0'/' ' terminator behaviour of the original processCommand checks.) +inline bool smExact(const char* line, const char* cmd, size_t cmdLen) { + return smPrefix(line, cmd, cmdLen) && (line[cmdLen] == '\0' || line[cmdLen] == ' '); +} + +// Classify a trimmed line. The caller guarantees it starts with "sm_"; a bare "sm_" or any +// unrecognized "sm_..." returns Unknown. +inline SmCommand classifySmCommand(const char* trimmed) { + if (smExact(trimmed, "sm_status", 9)) return SmCommand::Status; + if (smExact(trimmed, "sm_info", 7)) return SmCommand::Info; + if (smExact(trimmed, "sm_arm", 6)) return SmCommand::Arm; + if (smExact(trimmed, "sm_disarm", 9)) return SmCommand::Disarm; + if (smExact(trimmed, "sm_set_password", 15)) return SmCommand::SetPassword; + if (smExact(trimmed, "sm_wipe", 7)) return SmCommand::Wipe; + return SmCommand::Unknown; +} + +// Map a classified command to the caller's dispatch outcome. ONLY Wipe -> WipeRequest; this is the +// single choke point that guarantees no unrecognized command can ever reach the wipe path. +inline SmDispatch smDispatch(SmCommand cmd) { + switch (cmd) { + case SmCommand::Wipe: return SmDispatch::WipeRequest; + case SmCommand::Unknown: return SmDispatch::Unknown; + default: return SmDispatch::Handled; // Status/Info/Arm/Disarm/SetPassword + } +} + +} // namespace dashboard +} // namespace suicide + +#endif // SUICIDE_SM_COMMAND_H diff --git a/firmware/test_harness/native/test_sm_command.cpp b/firmware/test_harness/native/test_sm_command.cpp new file mode 100644 index 0000000..b1a1a7d --- /dev/null +++ b/firmware/test_harness/native/test_sm_command.cpp @@ -0,0 +1,68 @@ +// Native (host) unit test for the pure dashboard-command classifier — SmCommand.h. +// +// Zero Arduino/Serial/NVS dependencies, so it compiles + runs on any host toolchain: +// c++ -std=c++17 -I ../../bootgate test_sm_command.cpp -o t && ./t # exit 0 == all pass +// It is also `-fsyntax-only`-checked under the ESP32 cross toolchain in CI / the build harness. +// +// This locks in the W1 wipe-misfire fix (audit DEADMANS-AUDIT-2026-07-12 §GateInput_serial.cpp:313): +// the SINGLE safety invariant is that ONLY a clean `sm_wipe` ever maps to WipeRequest — a typo, an +// unknown/future command, or a mangled keyword must classify as Unknown and never reach the wipe path. +#include + +#include "SmCommand.h" + +using suicide::dashboard::SmCommand; +using suicide::dashboard::SmDispatch; +using suicide::dashboard::classifySmCommand; +using suicide::dashboard::smDispatch; + +int main() { + // --- recognized commands (exact token: keyword + end-of-string or a space) --- + assert(classifySmCommand("sm_status") == SmCommand::Status); + assert(classifySmCommand("sm_info") == SmCommand::Info); + assert(classifySmCommand("sm_arm") == SmCommand::Arm); + assert(classifySmCommand("sm_disarm") == SmCommand::Disarm); + assert(classifySmCommand("sm_set_password") == SmCommand::SetPassword); + assert(classifySmCommand("sm_wipe") == SmCommand::Wipe); + + // case-insensitive (commands may arrive upper/mixed case) + assert(classifySmCommand("SM_STATUS") == SmCommand::Status); + assert(classifySmCommand("Sm_Wipe") == SmCommand::Wipe); + + // a trailing space (with or without args) is still the same command + assert(classifySmCommand("sm_status ") == SmCommand::Status); + assert(classifySmCommand("sm_wipe ") == SmCommand::Wipe); + assert(classifySmCommand("sm_wipe now") == SmCommand::Wipe); + + // --- the misfire cases: these used to be routed into the authenticated wipe prompt --- + assert(classifySmCommand("sm_wipex") == SmCommand::Unknown); // extra byte -> NOT wipe + assert(classifySmCommand("sm_wipe\t") == SmCommand::Unknown); // tab-mangled -> NOT wipe + assert(classifySmCommand("sm_reboot") == SmCommand::Unknown); // unknown/future command + assert(classifySmCommand("sm_stat") == SmCommand::Unknown); // truncated typo + assert(classifySmCommand("sm_statusx") == SmCommand::Unknown); // trailing byte on a known keyword + assert(classifySmCommand("sm_") == SmCommand::Unknown); // bare prefix + assert(classifySmCommand("sm_ wipe") == SmCommand::Unknown); // space before the keyword + + // --- dispatch mapping: ONLY Wipe -> WipeRequest --- + assert(smDispatch(SmCommand::Wipe) == SmDispatch::WipeRequest); + assert(smDispatch(SmCommand::Unknown) == SmDispatch::Unknown); + assert(smDispatch(SmCommand::Status) == SmDispatch::Handled); + assert(smDispatch(SmCommand::Info) == SmDispatch::Handled); + assert(smDispatch(SmCommand::Arm) == SmDispatch::Handled); + assert(smDispatch(SmCommand::Disarm) == SmDispatch::Handled); + assert(smDispatch(SmCommand::SetPassword) == SmDispatch::Handled); + + // --- THE safety invariant: no input other than a clean `sm_wipe` can produce WipeRequest --- + const char* nonWipe[] = { + "sm_status", "sm_info", "sm_arm", "sm_disarm", "sm_set_password", + "sm_wipex", "sm_wipe\t", "sm_reboot", "sm_stat", "sm_statusx", "sm_", "sm_ wipe", + }; + for (const char* line : nonWipe) { + assert(smDispatch(classifySmCommand(line)) != SmDispatch::WipeRequest); + } + // ...and the clean forms DO. + assert(smDispatch(classifySmCommand("sm_wipe")) == SmDispatch::WipeRequest); + assert(smDispatch(classifySmCommand("sm_wipe ")) == SmDispatch::WipeRequest); + + return 0; // reaching here == every assert passed +}