One firmware: the Debug Console in every build, off until switched on, with the device's own token
CI / build (pull_request) Successful in 7m20s
Site / build (pull_request) Successful in 9s

There is no Debug Build any more (ADR 0010, issue #68, Q188 to Q195). The
console and the test commands are compiled into every firmware. It listens
only while Settings > Debug Console is on, which isn't the default; off,
neither its task nor its 4 KB ring exists. The token is made by the device
and shown on that page; a client proves it knows it by answering a challenge
with an HMAC, so it never crosses the network, and five wrong answers close
the console for a minute. DBG in the Status Bar while it listens.

Over USB serial only: debug on, debug token <value>, debug token new.
scripts/flash.sh --debug uses them to set a device up with the developer's
token. scripts/rdbg.py takes the token from -t, $RORO_DEBUG_TOKEN or the
file, answers the challenge, and fetches a release's ELF to decode a crash.

Gone: the cardputer-adv-debug environment, RORO_DEBUG, the +debug version,
scripts/debug_flags.py, update install ... force, and the rule that a Debug
Build doesn't install releases. Old clients and old firmwares don't talk to
each other.

Against the builds it replaces: 30 KB more flash and 88 bytes more static
RAM than the release, 4 KB less RAM than the Debug Build. 468 host tests.
Checked on the device: off by default, login, the pause after wrong tokens,
Safe Mode with the console, the setting surviving an update, debug off.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EhqxQ49eCju4CzKYNjZzwT
This commit is contained in:
2026-10-06 22:59:35 +02:00
co-authored by Claude Opus 5.5
parent 1353e6a5f9
commit c68741cc46
65 changed files with 1245 additions and 374 deletions
+127
View File
@@ -0,0 +1,127 @@
#include <unity.h>
#include <cstring>
#include <string>
#include "debug_auth.h"
using namespace roro::debug;
void setUp() {}
void tearDown() {}
static std::string hmacHex(const std::string& key, const std::string& message) {
uint8_t mac[32];
hmacSha256(reinterpret_cast<const uint8_t*>(key.data()), key.size(), reinterpret_cast<const uint8_t*>(message.data()),
message.size(), mac);
return toHex(mac, sizeof mac);
}
// RFC 4231, test cases 1, 2 and 6.
void test_hmac_matches_the_rfc_vectors() {
TEST_ASSERT_EQUAL_STRING("b0344c61d8db38535ca8afceaf0bf12b881dc200c9833da726e9376c2e32cff7",
hmacHex(std::string(20, '\x0b'), "Hi There").c_str());
TEST_ASSERT_EQUAL_STRING("5bdcc146bf60754e6a042426089575c75a003f089d2739839dec58b964ec3843",
hmacHex("Jefe", "what do ya want for nothing?").c_str());
TEST_ASSERT_EQUAL_STRING("60e431591ee0b67f0d8a26aacbf5b77f8e0bc6213728c5140546040f0ee37f54",
hmacHex(std::string(131, '\xaa'), "Test Using Larger Than Block-Size Key - Hash Key First").c_str());
}
void test_a_made_token_is_20_characters_without_lookalikes() {
uint8_t zeros[kTokenRandom] = {}, ones[kTokenRandom], counting[kTokenRandom];
memset(ones, 0xff, sizeof ones);
for (size_t i = 0; i < sizeof counting; i++) counting[i] = static_cast<uint8_t>(i);
TEST_ASSERT_EQUAL_STRING("00000000000000000000", makeToken(zeros).c_str());
TEST_ASSERT_EQUAL_STRING("ZZZZZZZZZZZZZZZZZZZZ", makeToken(ones).c_str());
TEST_ASSERT_EQUAL_STRING("000G40R40M30E209185G", makeToken(counting).c_str()); // as Python's reference gives
for (char c : makeToken(counting)) TEST_ASSERT_NULL(strchr("ILOU", c));
TEST_ASSERT_TRUE(validToken(makeToken(counting)));
}
void test_a_typed_token_is_tidied() {
TEST_ASSERT_EQUAL_STRING("K7QF3M2X9WBDHT4P6RNC", tidyToken("k7qf-3m2x 9wbd-HT4P-6rnc\n").c_str());
TEST_ASSERT_EQUAL_STRING("K7QF-3M2X-9WBD-HT4P-6RNC", groupToken("K7QF3M2X9WBDHT4P6RNC").c_str());
TEST_ASSERT_EQUAL_STRING("K7QF3M2X9WBDHT4P6RNC", tidyToken(groupToken("K7QF3M2X9WBDHT4P6RNC")).c_str());
// Misread as letters the alphabet doesn't have: taken for the digits they look like.
TEST_ASSERT_EQUAL_STRING("1010", tidyToken("IOlo").c_str());
}
void test_a_token_needs_16_to_64_printable_characters() {
TEST_ASSERT_FALSE(validToken(""));
TEST_ASSERT_FALSE(validToken("1234"));
TEST_ASSERT_FALSE(validToken(std::string(15, 'A')));
TEST_ASSERT_TRUE(validToken(std::string(16, 'A')));
TEST_ASSERT_TRUE(validToken(std::string(64, 'A')));
TEST_ASSERT_FALSE(validToken(std::string(65, 'A')));
TEST_ASSERT_FALSE(validToken("AAAAAAAAAAAAAAA A")); // untidied: a space
TEST_ASSERT_FALSE(validToken("aaaaaaaaaaaaaaaaaaaa")); // untidied: small letters
TEST_ASSERT_FALSE(validToken("AAAAAAAAAAAAAAAAAAAO")); // untidied: an O nobody could ever match
TEST_ASSERT_TRUE(validToken(tidyToken("hello-world-this-is-long")));
TEST_ASSERT_FALSE(validToken(std::string(16, '\x01')));
// The token of before, 32 hex digits from a file, still fits once tidied.
TEST_ASSERT_TRUE(validToken(tidyToken("0f1e2d3c4b5a69788796a5b4c3d2e1f0")));
}
// The same vector scripts/rdbg.py is checked against: Python's hmac.new(token, nonce, sha256).
void test_the_answer_is_the_hmac_of_the_nonce() {
uint8_t nonce[kNonceBytes];
for (size_t i = 0; i < sizeof nonce; i++) nonce[i] = static_cast<uint8_t>(i);
TEST_ASSERT_EQUAL_STRING("e1246c7e74d711cdb27d8d9346515829a01cf46d79fbbc1137885446f12ed2ba",
answerFor("K7QF3M2X9WBDHT4P6RNC", nonce).c_str());
nonce[0] ^= 1; // another challenge, another answer: a recorded one is no use
TEST_ASSERT_FALSE(answerFor("K7QF3M2X9WBDHT4P6RNC", nonce) == "e1246c7e74d711cdb27d8d9346515829a01cf46d79fbbc1137885446f12ed2ba");
}
void test_same_text() {
TEST_ASSERT_TRUE(sameText("abc", "abc"));
TEST_ASSERT_FALSE(sameText("abc", "abd"));
TEST_ASSERT_FALSE(sameText("ab", "abc"));
TEST_ASSERT_FALSE(sameText("abcd", "abc"));
TEST_ASSERT_FALSE(sameText("", "abc"));
TEST_ASSERT_TRUE(sameText("", ""));
}
void test_five_wrong_answers_lock_for_a_minute() {
AuthGate gate;
for (int i = 0; i < 4; i++) {
TEST_ASSERT_FALSE(gate.failed(1000 + i));
TEST_ASSERT_FALSE(gate.locked(1000 + i));
}
TEST_ASSERT_TRUE(gate.failed(2000)); // the fifth starts the pause
TEST_ASSERT_TRUE(gate.locked(2001));
TEST_ASSERT_FALSE(gate.failed(2002)); // nothing is counted meanwhile
TEST_ASSERT_TRUE(gate.locked(2000 + AuthGate::kLockMs - 1));
TEST_ASSERT_FALSE(gate.locked(2000 + AuthGate::kLockMs));
TEST_ASSERT_EQUAL(0, gate.failures()); // and the count starts again
}
void test_a_right_answer_forgets_the_wrong_ones() {
AuthGate gate;
for (int i = 0; i < 4; i++) gate.failed(i);
gate.succeeded();
for (int i = 0; i < 4; i++) TEST_ASSERT_FALSE(gate.failed(10 + i));
TEST_ASSERT_FALSE(gate.locked(20));
}
void test_the_lock_holds_across_the_clock_wrap() {
AuthGate gate;
uint32_t t = 0xFFFFFFF0u; // 16 ms before millis() wraps
for (int i = 0; i < 5; i++) gate.failed(t);
TEST_ASSERT_TRUE(gate.locked(t + 100)); // after the wrap: still locked
TEST_ASSERT_TRUE(gate.locked(t + AuthGate::kLockMs - 1));
TEST_ASSERT_FALSE(gate.locked(t + AuthGate::kLockMs));
}
int main(int, char**) {
UNITY_BEGIN();
RUN_TEST(test_hmac_matches_the_rfc_vectors);
RUN_TEST(test_a_made_token_is_20_characters_without_lookalikes);
RUN_TEST(test_a_typed_token_is_tidied);
RUN_TEST(test_a_token_needs_16_to_64_printable_characters);
RUN_TEST(test_the_answer_is_the_hmac_of_the_nonce);
RUN_TEST(test_same_text);
RUN_TEST(test_five_wrong_answers_lock_for_a_minute);
RUN_TEST(test_a_right_answer_forgets_the_wrong_ones);
RUN_TEST(test_the_lock_holds_across_the_clock_wrap);
return UNITY_END();
}
-2
View File
@@ -115,8 +115,6 @@ void test_which_releases_are_updates() {
TEST_ASSERT_FALSE(shouldAnnounce(r, "v0.10.0", "v0.11.0", "")); // it rolled back here before
TEST_ASSERT_FALSE(shouldAnnounce(r, "v0.10.0", "", "v0.11.0")); // already said so
TEST_ASSERT_TRUE(shouldAnnounce(r, "v0.10.0", "v0.10.5", "v0.10.9"));
TEST_ASSERT_TRUE(isDebugBuild("v0.10.0-3-gabc+debug"));
TEST_ASSERT_FALSE(isDebugBuild("v0.10.0"));
}
void test_only_the_projects_downloads_are_fetched() {
+37
View File
@@ -185,6 +185,41 @@ void test_dns_and_ntp_are_checked() {
TEST_ASSERT_TRUE(s.setString(Setting::Ntp2, ""));
}
// ADR 0010: off, and no token, until the owner switches the console on. A stored value that isn't
// valid counts as missing, so a damaged store can't switch it on or leave a weak token.
void test_the_debug_console_is_off_and_has_no_token_by_default() {
MemoryStore store;
EventBus bus;
Settings s(store, bus);
s.load();
TEST_ASSERT_FALSE(s.getBool(Setting::DebugConsole));
TEST_ASSERT_EQUAL_STRING("", s.getString(Setting::DebugToken).c_str());
MemoryStore damaged;
damaged.ints["debug_on"] = 7; // not a bool
damaged.strings["debug_token"] = "1234"; // too short
Settings d(damaged, bus);
d.load();
TEST_ASSERT_FALSE(d.getBool(Setting::DebugConsole));
TEST_ASSERT_EQUAL_STRING("", d.getString(Setting::DebugToken).c_str());
}
void test_a_debug_token_must_be_valid_or_empty() {
MemoryStore store;
EventBus bus;
Settings s(store, bus);
s.load();
TEST_ASSERT_FALSE(s.setString(Setting::DebugToken, "1234"));
TEST_ASSERT_FALSE(s.setString(Setting::DebugToken, "k7qf-3m2x-9wbd-ht4p-6rnc")); // not tidied
TEST_ASSERT_TRUE(s.setString(Setting::DebugToken, "K7QF3M2X9WBDHT4P6RNC"));
TEST_ASSERT_TRUE(s.setBool(Setting::DebugConsole, true));
Settings again(store, bus);
again.load();
TEST_ASSERT_TRUE(again.getBool(Setting::DebugConsole));
TEST_ASSERT_EQUAL_STRING("K7QF3M2X9WBDHT4P6RNC", again.getString(Setting::DebugToken).c_str());
TEST_ASSERT_TRUE(again.setString(Setting::DebugToken, "")); // forgotten
}
int main() {
UNITY_BEGIN();
RUN_TEST(test_defaults_when_store_is_empty);
@@ -201,5 +236,7 @@ int main() {
RUN_TEST(test_storage_keys_fit_nvs_limit);
RUN_TEST(test_dns_and_ntp_defaults);
RUN_TEST(test_dns_and_ntp_are_checked);
RUN_TEST(test_the_debug_console_is_off_and_has_no_token_by_default);
RUN_TEST(test_a_debug_token_must_be_valid_or_empty);
return UNITY_END();
}
@@ -97,6 +97,17 @@ void test_wifi_is_a_page() {
TEST_ASSERT_EQUAL_STRING("On", f.menu.value(wifi).c_str());
}
void test_the_debug_console_is_a_page_that_says_off() {
Fixture f;
int row = f.row(SettingsMenu::Row::DebugConsole);
TEST_ASSERT_TRUE(row >= 0);
TEST_ASSERT_EQUAL(static_cast<int>(SettingsMenu::Kind::Page), static_cast<int>(f.menu.kind(row)));
TEST_ASSERT_EQUAL_STRING("Debug Console", f.menu.label(row).c_str());
TEST_ASSERT_EQUAL_STRING("Off", f.menu.value(row).c_str()); // Q189
f.settings.setBool(Setting::DebugConsole, true);
TEST_ASSERT_EQUAL_STRING("On", f.menu.value(row).c_str());
}
void test_names_are_text_rows_with_their_byte_limits() {
Fixture f;
int shortName = f.row(SettingsMenu::Row::ShortName);
@@ -159,5 +170,6 @@ int main() {
RUN_TEST(test_pause_gnss_for_lora_is_off_by_default);
RUN_TEST(test_check_for_updates_is_on_by_default);
RUN_TEST(test_gnss_and_coordinate_rows_toggle);
RUN_TEST(test_the_debug_console_is_a_page_that_says_off);
return UNITY_END();
}