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();
}