diff --git a/.github/workflows/run-unit-tests.yml b/.github/workflows/run-unit-tests.yml index 4f5ddc0cd..b839e3f4b 100644 --- a/.github/workflows/run-unit-tests.yml +++ b/.github/workflows/run-unit-tests.yml @@ -274,6 +274,9 @@ jobs: mkdir -p .pio/libdeps/room-mail-test pio pkg install --global --library 'rweather/Crypto@0.4.0' --storage-dir .pio/libdeps/room-mail-test + - name: Verify shared SHA-512 signing against the original backend and RFC8032 + run: python3 -B test/test_ed25519_shared_sha512.py -v + - name: Verify ArduinoJson pin run: | python3 -B scripts/check_arduinojson_pin.py --self-test diff --git a/examples/simple_repeater/MyMesh.cpp b/examples/simple_repeater/MyMesh.cpp index 72b2a110e..0fd7f09d5 100644 --- a/examples/simple_repeater/MyMesh.cpp +++ b/examples/simple_repeater/MyMesh.cpp @@ -3531,8 +3531,8 @@ void MyMesh::onControlDataRecv(mesh::Packet* packet) { const auto& secondary = profiles->secondary; data[length - 1] = 1 | (static_cast(secondary.mode) << 1) | (profiles->secondary_temporary ? 0x08 : 0); - const uint32_t frequency = static_cast(lroundf(secondary.params.freq * 1000.0f)); - const uint32_t bandwidth = static_cast(lroundf(secondary.params.bw * 1000.0f)); + const uint32_t frequency = static_cast(secondary.params.freq * 1000.0f + 0.5f); + const uint32_t bandwidth = static_cast(secondary.params.bw * 1000.0f + 0.5f); for (unsigned shift = 0; shift < 32; shift += 8) data[length++] = frequency >> shift; for (unsigned shift = 0; shift < 32; shift += 8) data[length++] = bandwidth >> shift; data[length++] = secondary.params.sf; @@ -5503,12 +5503,11 @@ bool MyMesh::handleGuestCommand(const char* command, char* reply, size_t capacit } else if (get) { snprintf(reply, capacity, "> %s%s", guest_login.enabled() ? "on" : "off", guest_login.healthy() ? "" : " (settings unavailable)"); - } else if (!guest_login.setEnabled(strcmp(value, "on") == 0)) { - if (!guest_login.enabled()) acl.removeGuestClients(); - snprintf(reply, capacity, "Err - guest setting save unconfirmed; reboot may differ"); } else { + const bool saved = guest_login.setEnabled(strcmp(value, "on") == 0); if (!guest_login.enabled()) acl.removeGuestClients(); - snprintf(reply, capacity, "OK - guest %s", guest_login.enabled() ? "on" : "off"); + if (saved) snprintf(reply, capacity, "OK - guest %s", guest_login.enabled() ? "on" : "off"); + else snprintf(reply, capacity, "Err - guest setting save unconfirmed; reboot may differ"); } return true; } diff --git a/lib/ed25519/add_scalar.c b/lib/ed25519/add_scalar.c index 77f1fd8ce..a334038fc 100644 --- a/lib/ed25519/add_scalar.c +++ b/lib/ed25519/add_scalar.c @@ -1,7 +1,7 @@ #include "ed_25519.h" #include "ge.h" #include "sc.h" -#include "sha512.h" +#include "ed_sha512.h" /* see http://crypto.stackexchange.com/a/6215/4697 */ diff --git a/lib/ed25519/ed_sha512.h b/lib/ed25519/ed_sha512.h new file mode 100644 index 000000000..29e658c3b --- /dev/null +++ b/lib/ed25519/ed_sha512.h @@ -0,0 +1,41 @@ +#ifndef ED25519_SHA512_H +#define ED25519_SHA512_H + +#include + +#include "fixedint.h" + +#ifndef ED25519_USE_CRYPTO_SHA512 +#define ED25519_USE_CRYPTO_SHA512 0 +#endif + +#if ED25519_USE_CRYPTO_SHA512 +/* C-owned, aligned storage for the optional C++ SHA512 implementation. */ +typedef union sha512_context_ { + uint64_t alignment; + unsigned char opaque[256]; +} sha512_context; +#else +/* state */ +typedef struct sha512_context_ { + uint64_t length, state[8]; + size_t curlen; + unsigned char buf[128]; +} sha512_context; +#endif + +#ifdef __cplusplus +extern "C" { +#endif + +/* A completed context must be initialized again before reuse. */ +int sha512_init(sha512_context * md); +int sha512_final(sha512_context * md, unsigned char *out); +int sha512_update(sha512_context * md, const unsigned char *in, size_t inlen); +int sha512(const unsigned char *message, size_t message_len, unsigned char *out); + +#ifdef __cplusplus +} +#endif + +#endif diff --git a/lib/ed25519/ed_sha512_crypto.cpp b/lib/ed25519/ed_sha512_crypto.cpp new file mode 100644 index 000000000..d1d74e0ba --- /dev/null +++ b/lib/ed25519/ed_sha512_crypto.cpp @@ -0,0 +1,48 @@ +#include "ed_sha512.h" + +#if ED25519_USE_CRYPTO_SHA512 +#include +#include + +// Share the SHA-512 already used by Crypto's Ed25519 verifier. Contexts remain +// caller-owned; hashing and signing allocate no heap and share no mutable state. +static_assert(sizeof(SHA512) <= sizeof(sha512_context), "SHA512 context capacity"); +static_assert(alignof(SHA512) <= alignof(sha512_context), "SHA512 context alignment"); + +namespace { +SHA512* implementation(sha512_context* context) { + return reinterpret_cast(context->opaque); +} +} + +extern "C" int sha512_init(sha512_context* context) { + if (!context) return 1; + new (context->opaque) SHA512(); + return 0; +} + +extern "C" int sha512_update(sha512_context* context, const unsigned char* input, + size_t length) { + if (!context || !input) return 1; + implementation(context)->SHA512::update(input, length); + return 0; +} + +extern "C" int sha512_final(sha512_context* context, unsigned char* output) { + if (!context || !output) return 1; + SHA512* hash = implementation(context); + hash->SHA512::finalize(output, SHA512::HASH_SIZE); + hash->SHA512::~SHA512(); + return 0; +} + +extern "C" int sha512(const unsigned char* message, size_t length, + unsigned char* output) { + if (!message || !output) return 1; + sha512_context context; + sha512_init(&context); + sha512_update(&context, message, length); + sha512_final(&context, output); + return 0; +} +#endif diff --git a/lib/ed25519/keypair.c b/lib/ed25519/keypair.c index da789f756..9c7304068 100644 --- a/lib/ed25519/keypair.c +++ b/lib/ed25519/keypair.c @@ -1,5 +1,5 @@ #include "ed_25519.h" -#include "sha512.h" +#include "ed_sha512.h" #include "ge.h" diff --git a/lib/ed25519/sha512.c b/lib/ed25519/sha512.c index e07fb92b2..63b59a5e3 100644 --- a/lib/ed25519/sha512.c +++ b/lib/ed25519/sha512.c @@ -10,7 +10,9 @@ */ #include "fixedint.h" -#include "sha512.h" +#include "ed_sha512.h" + +#if !ED25519_USE_CRYPTO_SHA512 /* the K array */ static const uint64_t K[80] = { @@ -303,3 +305,5 @@ int sha512(const unsigned char *message, size_t message_len, unsigned char *out) if ((ret = sha512_final(&ctx, out))) return ret; return 0; } + +#endif diff --git a/lib/ed25519/sha512.h b/lib/ed25519/sha512.h deleted file mode 100644 index a34dd5e42..000000000 --- a/lib/ed25519/sha512.h +++ /dev/null @@ -1,21 +0,0 @@ -#ifndef SHA512_H -#define SHA512_H - -#include - -#include "fixedint.h" - -/* state */ -typedef struct sha512_context_ { - uint64_t length, state[8]; - size_t curlen; - unsigned char buf[128]; -} sha512_context; - - -int sha512_init(sha512_context * md); -int sha512_final(sha512_context * md, unsigned char *out); -int sha512_update(sha512_context * md, const unsigned char *in, size_t inlen); -int sha512(const unsigned char *message, size_t message_len, unsigned char *out); - -#endif diff --git a/lib/ed25519/sign.c b/lib/ed25519/sign.c index 463b02bff..83f0b51da 100644 --- a/lib/ed25519/sign.c +++ b/lib/ed25519/sign.c @@ -1,5 +1,5 @@ #include "ed_25519.h" -#include "sha512.h" +#include "ed_sha512.h" #include "ge.h" #include "sc.h" diff --git a/lib/ed25519/verify.c b/lib/ed25519/verify.c index 4d8de2532..4e0f10e0d 100644 --- a/lib/ed25519/verify.c +++ b/lib/ed25519/verify.c @@ -1,5 +1,5 @@ #include "ed_25519.h" -#include "sha512.h" +#include "ed_sha512.h" #include "ge.h" #include "sc.h" diff --git a/platformio.ini b/platformio.ini index 7d650409e..80f7d7448 100644 --- a/platformio.ini +++ b/platformio.ini @@ -207,6 +207,7 @@ build_flags = ${arduino_base.build_flags} ; PendSV reaches this FreeRTOS function from assembly, outside the LTO graph. -Wl,--undefined=vTaskSwitchContext -D NRF52_PLATFORM + -D ED25519_USE_CRYPTO_SHA512=1 ; share the SHA-512 used by signature verification -D LFS_NO_ASSERT=1 -D EXTRAFS=1 -D MESH_NRF52_LOOP_STACK_WORDS=2048 @@ -373,6 +374,7 @@ extra_scripts = post:arch/stm32/build_hex.py post:scripts/check_firmware_ram.py build_flags = ${arduino_base.build_flags} -D STM32_PLATFORM + -D ED25519_USE_CRYPTO_SHA512=1 ; avoid linking two SHA-512 implementations -D ED25519_COMPACT_BASE=1 -I src/helpers/stm32 -I $PROJECT_PACKAGES_DIR/framework-arduinoststm32/libraries/SubGhz/src diff --git a/src/RadioProfiles.h b/src/RadioProfiles.h index 578308750..739dfd228 100644 --- a/src/RadioProfiles.h +++ b/src/RadioProfiles.h @@ -248,14 +248,15 @@ class RadioProfiles { } uint32_t listenUs(uint8_t profile, uint16_t slow_preamble = 0) const { const uint8_t slow = slowerProfile(); - const double minimum = minimumListenUs(params(profile), enabled() && profile == slow); - if (!enabled() || profile == slow) return (uint32_t)ceil(minimum); + const uint32_t minimum = minimumListenUs(params(profile), enabled() && profile == slow); + if (!enabled() || profile == slow) return minimum; if (!slow_preamble) slow_preamble = preamble(slow, 32); const double available = double(slow_preamble) / 2.0 * symbolUs(params(slow)) - minimumListenUs(params(slow), true) - 2.0 * switchBudgetUs() - LoopBudgetUs; // Rejected previews must not cause an out-of-range floating-to-int cast. + // In the positive branch, truncation already rounds down to whole us. return available > UINT32_MAX ? UINT32_MAX - : available > minimum ? (uint32_t)floor(available) : (uint32_t)minimum; + : available > minimum ? (uint32_t)available : minimum; } ChirpTiming chirpTiming() const { if (!enabled()) return {}; diff --git a/src/helpers/CLIPage.h b/src/helpers/CLIPage.h new file mode 100644 index 000000000..06fb049c3 --- /dev/null +++ b/src/helpers/CLIPage.h @@ -0,0 +1,22 @@ +#pragma once + +namespace mesh { +namespace cli { + +// Empty arguments select page 1. Zero signals invalid decimal page syntax. +inline unsigned parseCliPage(const char* arg) { + while (*arg == ' ' || *arg == '\t') ++arg; + unsigned page = 1; + if (*arg >= '0' && *arg <= '9') { + page = 0; + do { + page = page * 10 + unsigned(*arg++ - '0'); + if (page > 65535) return 0; + } while (*arg >= '0' && *arg <= '9'); + } + while (*arg == ' ' || *arg == '\t' || *arg == '\r' || *arg == '\n') ++arg; + return *arg ? 0 : page; +} + +} // namespace cli +} // namespace mesh diff --git a/src/helpers/ClientACLCLI.h b/src/helpers/ClientACLCLI.h index 92c77485f..e2f2c33fa 100644 --- a/src/helpers/ClientACLCLI.h +++ b/src/helpers/ClientACLCLI.h @@ -1,6 +1,7 @@ #pragma once #include "ClientACL.h" +#include "CLIPage.h" #include #include #include @@ -21,17 +22,8 @@ inline bool handleACLGet(const ClientACL& acl, const char* command, if (local && *arg == 0) return false; if (reply == nullptr || capacity == 0) return true; - while (*arg == ' ' || *arg == '\t') ++arg; - unsigned page = 1; - if (*arg >= '0' && *arg <= '9') { - page = 0; - do { - page = page * 10 + unsigned(*arg++ - '0'); - if (page > 65535) break; - } while (*arg >= '0' && *arg <= '9'); - } - while (*arg == ' ' || *arg == '\t' || *arg == '\r' || *arg == '\n') ++arg; - if (*arg || page == 0 || page > 65535) { + const unsigned page = parseCliPage(arg); + if (page == 0) { snprintf(reply, capacity, "Err - usage: get acl [page]"); return true; } diff --git a/src/helpers/GuestCLI.h b/src/helpers/GuestCLI.h index 8d0ac0bce..bfc48daff 100644 --- a/src/helpers/GuestCLI.h +++ b/src/helpers/GuestCLI.h @@ -1,6 +1,7 @@ #pragma once #include "ClientACL.h" +#include "CLIPage.h" #include #include #include @@ -29,24 +30,17 @@ inline bool handleGuestsGet(const ClientACL& acl, const char* command, if (*arg && *arg != ' ' && *arg != '\t' && *arg != '\r' && *arg != '\n') return false; if (reply == nullptr || capacity == 0) return true; - while (*arg == ' ' || *arg == '\t') ++arg; - unsigned page = 1; - if (*arg >= '0' && *arg <= '9') { - page = 0; - do { - page = page * 10 + unsigned(*arg++ - '0'); - if (page > 65535) break; - } while (*arg >= '0' && *arg <= '9'); - } - while (*arg == ' ' || *arg == '\t' || *arg == '\r' || *arg == '\n') ++arg; - if (*arg || page == 0 || page > 65535) { + const unsigned page = parseCliPage(arg); + if (page == 0) { snprintf(reply, capacity, "Err - usage: get guests [page]"); return true; } unsigned total = 0; + const ClientInfo* selected = nullptr; for (int i = 0; i < acl.getNumClients(); ++i) { - if (isLiveGuest(*acl.getClientByIdx(i))) ++total; + const auto* client = acl.getClientByIdx(i); + if (isLiveGuest(*client) && ++total == page) selected = client; } if (total == 0 && page == 1) { snprintf(reply, capacity, "Guests: empty"); @@ -58,24 +52,16 @@ inline bool handleGuestsGet(const ClientACL& acl, const char* command, return true; } - unsigned index = 0; - for (int i = 0; i < acl.getNumClients(); ++i) { - const auto* client = acl.getClientByIdx(i); - if (!isLiveGuest(*client) || ++index != page) continue; + if (selected) { char key[PUB_KEY_SIZE * 2 + 1]; - Utils::toHex(key, client->id.pub_key, PUB_KEY_SIZE); - const int needed = snprintf(nullptr, 0, "Guests %u/%u (%u)\n%s last=%lu", + Utils::toHex(key, selected->id.pub_key, PUB_KEY_SIZE); + const int needed = snprintf(reply, capacity, "Guests %u/%u (%u)\n%s last=%lu", page, pages, total, key, - static_cast(client->last_activity)); + static_cast(selected->last_activity)); // A small caller buffer must never turn a complete key into a prefix. if (needed < 0 || size_t(needed) >= capacity) { snprintf(reply, capacity, "Err - guest reply too small"); - } else { - snprintf(reply, capacity, "Guests %u/%u (%u)\n%s last=%lu", - page, pages, total, key, - static_cast(client->last_activity)); } - return true; } return true; } diff --git a/src/helpers/LoginHistory.h b/src/helpers/LoginHistory.h index e938e97b3..238fe38de 100644 --- a/src/helpers/LoginHistory.h +++ b/src/helpers/LoginHistory.h @@ -1,5 +1,6 @@ #pragma once +#include "CLIPage.h" #include #include #include @@ -35,11 +36,11 @@ class LoginHistory { while (index < _size && memcmp(_entries[index].key, key, KEY_SIZE) != 0) ++index; Entry latest = {}; + memcpy(latest.key, key, KEY_SIZE); if (index < _size) { - latest = _entries[index]; + latest.attempts = _entries[index].attempts; if (latest.attempts != UINT32_MAX) ++latest.attempts; } else { - memcpy(latest.key, key, KEY_SIZE); latest.attempts = 1; if (_size < CAPACITY) ++_size; index = _size - 1; @@ -82,17 +83,8 @@ inline bool handleLoginsGet(const LoginHistory& history, const char* command, if (*arg && *arg != ' ' && *arg != '\t' && *arg != '\r' && *arg != '\n') return false; if (reply == nullptr || capacity == 0) return true; - while (*arg == ' ' || *arg == '\t') ++arg; - unsigned page = 1; - if (*arg >= '0' && *arg <= '9') { - page = 0; - do { - page = page * 10 + unsigned(*arg++ - '0'); - if (page > 65535) break; - } while (*arg >= '0' && *arg <= '9'); - } - while (*arg == ' ' || *arg == '\t' || *arg == '\r' || *arg == '\n') ++arg; - if (*arg || page == 0 || page > 65535) { + const unsigned page = parseCliPage(arg); + if (page == 0) { snprintf(reply, capacity, "Err - usage: get logins [page]"); return true; } @@ -108,9 +100,10 @@ inline bool handleLoginsGet(const LoginHistory& history, const char* command, return true; } - char rows[2][80] = {}; - unsigned count = 0; - for (unsigned index = (page - 1) * 2; index < total && count < 2; ++index) { + int used = snprintf(reply, capacity, "Logins %u/%u (%u)", page, pages, total); + const unsigned end = page * 2 < total ? page * 2 : total; + for (unsigned index = (page - 1) * 2; index < end + && used >= 0 && size_t(used) < capacity; ++index) { const auto& entry = *history.get(index); char key[13]; static const char hex[] = "0123456789ABCDEF"; @@ -119,20 +112,15 @@ inline bool handleLoginsGet(const LoginHistory& history, const char* command, key[i * 2 + 1] = hex[entry.key[i] & 0x0F]; } key[12] = 0; - snprintf(rows[count], sizeof(rows[count]), "%s n=%lu last=%lu %s", key, + const int written = snprintf(reply + used, capacity - size_t(used), "\n%s n=%lu last=%lu %s", key, static_cast(entry.attempts), static_cast(entry.timestamp), loginResultName(entry.result)); - ++count; + if (written < 0) { used = -1; break; } + used += written; } - const char* separator = count == 2 ? "\n" : ""; - const int needed = snprintf(nullptr, 0, "Logins %u/%u (%u)\n%s%s%s", - page, pages, total, rows[0], separator, rows[1]); // Never accidentally expose a partly formatted public-key prefix. - if (needed < 0 || size_t(needed) >= capacity) { + if (used < 0 || size_t(used) >= capacity) { snprintf(reply, capacity, "Err - login reply too small"); - } else { - snprintf(reply, capacity, "Logins %u/%u (%u)\n%s%s%s", - page, pages, total, rows[0], separator, rows[1]); } return true; } diff --git a/src/helpers/RadioProfileCLI.cpp b/src/helpers/RadioProfileCLI.cpp index 5fad95b8a..bd29676f9 100644 --- a/src/helpers/RadioProfileCLI.cpp +++ b/src/helpers/RadioProfileCLI.cpp @@ -726,10 +726,11 @@ void RadioProfileCLI::appendChirpWarning(char* reply, size_t capacity, const Rad const bool short_a = preview.primary.preamble && preview.primary.preamble < timing.preamble[0]; const bool short_b = preview.secondary.params.preamble && preview.secondary.params.preamble < timing.preamble[1]; const bool a = timing.preamble[0] > 32 || short_a, b = timing.preamble[1] > 32 || short_b; - if (a && b) appendReply(reply, capacity, "; WARN recommended preamble: radio=%.0f,radio2=%.0f", - timing.preamble[0], timing.preamble[1]); - else if (a || b) appendReply(reply, capacity, "; WARN recommended preamble: %s=%.0f", - a ? "radio" : "radio2", timing.preamble[a ? 0 : 1]); + if (a || b) { + appendReply(reply, capacity, "; WARN recommended preamble: %s=%.0f", + a ? "radio" : "radio2", timing.preamble[a ? 0 : 1]); + if (a && b) appendReply(reply, capacity, ",radio2=%.0f", timing.preamble[1]); + } if (short_a || short_b) appendReply(reply, capacity, "; short override: %s", short_a && short_b ? "radio,radio2" : short_a ? "radio" : "radio2"); @@ -783,16 +784,26 @@ bool RadioProfileCLI::handle(const char* command, char* reply, size_t capacity, memcpy(key, text, n); key[n] = 0; const char* args = text + n; while (*args == ' ' || *args == '\t') ++args; - const bool base = !strcmp(key, "radio2"); - const bool temporary = !strcmp(key, "tempradio2"); - const bool scheduled = !strcmp(key, "radioat2") || !strcmp(key, "tempradioat2"); - const bool scheduled_temp = !strcmp(key, "tempradioat2"); - const bool crossing = !strcmp(key, "radio2.cross"); - const bool replies = infrastructure_replies_ && !strcmp(key, "tx.reply"); - const bool status = !strcmp(key, "radio2.status"); - const bool scan = !strcmp(key, "radio2.scan"); - const bool timing = !strcmp(key, "radio2.timing") || !strcmp(key, "radio.timing"); - if (!base && !temporary && !scheduled && !crossing && !replies && !status && !scan && !timing) return false; + enum { Base, Temporary, Scheduled, ScheduledTemporary, Crossing, Replies, + Status, Scan, Timing, TimingAlias, KeyCount }; + static const char keys[] = "radio2\0tempradio2\0radioat2\0tempradioat2\0radio2.cross\0" + "tx.reply\0radio2.status\0radio2.scan\0radio2.timing\0radio.timing"; + const char* candidate = keys; + unsigned kind = 0; + while (kind < KeyCount && strcmp(key, candidate)) { + candidate += strlen(candidate) + 1; + ++kind; + } + if (kind == KeyCount || (kind == Replies && !infrastructure_replies_)) return false; + const bool base = kind == Base; + const bool temporary = kind == Temporary; + const bool scheduled = kind == Scheduled || kind == ScheduledTemporary; + const bool scheduled_temp = kind == ScheduledTemporary; + const bool crossing = kind == Crossing; + const bool replies = kind == Replies; + const bool status = kind == Status; + const bool scan = kind == Scan; + const bool timing = kind == Timing || kind == TimingAlias; if (!radio_ || !radio_->profiles()) { cli::formatError(reply, capacity, "radio profiles unsupported"); return true; } if (command == text && *args && verb == Get && (temporary || base)) verb = Set; if (remote_origin && !remote_command_ @@ -802,6 +813,10 @@ bool RadioProfileCLI::handle(const char* command, char* reply, size_t capacity, if (hasReplyMutation() && verb != Get) { cli::formatError(reply, capacity, "radio acknowledgement pending"); return true; } + if ((timing || scan || status) && (verb != Get || *args)) { + snprintf(reply, capacity, "Error: %s is read-only", timing ? "radio timing" : key); + return true; + } if (replies) { const auto* p = radio_->profiles(); if (verb == Get && !*args) { @@ -836,7 +851,6 @@ bool RadioProfileCLI::handle(const char* command, char* reply, size_t capacity, return true; } if (timing) { - if (verb != Get || *args) { cli::formatError(reply, capacity, "radio timing is read-only"); return true; } const auto& p = *radio_->profiles(); const auto t = p.chirpTiming(); if (!p.enabled()) snprintf(reply, capacity, "> off"); @@ -872,7 +886,6 @@ bool RadioProfileCLI::handle(const char* command, char* reply, size_t capacity, return true; } if (scan) { - if (verb != Get || *args) { cli::formatError(reply, capacity, "radio2.scan is read-only"); return true; } const auto& p = *radio_->profiles(); if (!p.enabled()) snprintf(reply, capacity, "> off"); else { @@ -886,7 +899,6 @@ bool RadioProfileCLI::handle(const char* command, char* reply, size_t capacity, return true; } if (status) { - if (verb != Get || *args) { cli::formatError(reply, capacity, "radio2.status is read-only"); return true; } if (hasReplyMutation()) { snprintf(reply, capacity, "> radio2 change pending %s; previous channel retained", remote_delivered_ ? "storage commit (retrying)" : "reply transmission"); diff --git a/test/fixtures/client_acl_cli/test_client_acl_cli.cpp b/test/fixtures/client_acl_cli/test_client_acl_cli.cpp index 824bb06d4..9057a791f 100644 --- a/test/fixtures/client_acl_cli/test_client_acl_cli.cpp +++ b/test/fixtures/client_acl_cli/test_client_acl_cli.cpp @@ -128,7 +128,8 @@ static void rejects_bad_pages() { acl.load(&fs, self); add(acl, 0, 3); for (const char* command : { - "get acl 0", "get acl -1", "get acl +1", "get acl 1x", "get acl 1 2", + "get acl 0", "get acl 0000000000000000000000000000000000000000", + "get acl -1", "get acl +1", "get acl 1x", "get acl 1 2", "get acl\n1", "get acl 1.0", "get acl page 1", "get acl 65536", "get acl 4294967296", "get acl 9999999999999999999999999999999999", "get acl 1\nsetperm key 3"}) { CHECK(query(acl, command) == "Err - usage: get acl [page]"); diff --git a/test/fixtures/guest_cli/test_guest_cli.cpp b/test/fixtures/guest_cli/test_guest_cli.cpp index 747021c04..1fec1ac98 100644 --- a/test/fixtures/guest_cli/test_guest_cli.cpp +++ b/test/fixtures/guest_cli/test_guest_cli.cpp @@ -119,7 +119,8 @@ static void rejects_bad_pages() { acl.load(&fs, self); add(acl, 0, PERM_ACL_GUEST); for (const char* command : { - "get guests 0", "get guests -1", "get guests +1", "get guests 1x", + "get guests 0", "get guests 0000000000000000000000000000000000000000", + "get guests -1", "get guests +1", "get guests 1x", "get guests\n1", "get guests 1 2", "get guests 1.0", "get guests page 1", "get guests 65536", "get guests 4294967296", "get guests 99999999999999999999999999", "get guests 1\nset guest off"}) { diff --git a/test/fixtures/login_history/test_login_history.cpp b/test/fixtures/login_history/test_login_history.cpp index e41ff76ea..ca9c849cf 100644 --- a/test/fixtures/login_history/test_login_history.cpp +++ b/test/fixtures/login_history/test_login_history.cpp @@ -144,7 +144,8 @@ static void strict_page_and_command_parsing() { LoginHistory history; record(history, 0, 0); const char* invalid[] = { - "get logins 0", "get logins -1", "get logins +1", "get logins 1x", + "get logins 0", "get logins 0000000000000000000000000000000000000000", + "get logins -1", "get logins +1", "get logins 1x", "get logins\n1", "get logins 1 2", "get logins 1.0", "get logins page 1", "get logins 65536", "get logins 4294967296", "get logins 9999999999999999999999999999", "get logins 1\nset guest on", diff --git a/test/fixtures/radio_profiles/chirp_math.cpp b/test/fixtures/radio_profiles/chirp_math.cpp index bd94f2c34..36e7b6f9d 100644 --- a/test/fixtures/radio_profiles/chirp_math.cpp +++ b/test/fixtures/radio_profiles/chirp_math.cpp @@ -24,6 +24,19 @@ static void checkSafePreamble(unsigned sf, float bw, uint16_t symbols) { int main() { Profiles fresh; assert(fresh.cross==mesh::RadioCrossMode::Off && !fresh.canCross()); + // Listen durations round upward for the minimum, downward for a larger + // fast visit, and saturate rejected previews before converting to integers. + Profiles listen_bounds; + listen_bounds.primary=params(5,1000); + assert(listen_bounds.listenUs(0)==148); + listen_bounds.secondary.params=params(5,500); + listen_bounds.secondary.mode=mesh::RadioProfileMode::Rx; + assert(listen_bounds.listenUs(0,1)==148); + assert(listen_bounds.listenUs(0,32)==729); + listen_bounds.secondary.params.bw=7.8f; + assert(listen_bounds.listenUs(0,32)==46769); + listen_bounds.secondary.params.bw=1e-30f; + assert(listen_bounds.listenUs(0,1)==UINT32_MAX); // Sharing the read-only validation table must preserve every supported // value and the strict float tolerance on both sides of its boundary. for (float bw : {7.8f, 10.4f, 15.6f, 20.8f, 31.25f, 41.7f, diff --git a/test/test_ed25519_shared_sha512.py b/test/test_ed25519_shared_sha512.py new file mode 100644 index 000000000..685cd4889 --- /dev/null +++ b/test/test_ed25519_shared_sha512.py @@ -0,0 +1,239 @@ +"""Exercise both Ed25519 signing hash backends with the real Crypto verifier.""" +from pathlib import Path +import hashlib +import os +import shutil +import subprocess +import tempfile +import unittest + +ROOT = Path(__file__).resolve().parents[1] +ED25519 = ROOT / "lib/ed25519" +LENGTHS = (0, 1, 63, 64, 111, 112, 127, 128, 129, 239, 240, 255, + 256, 257, 1024, 4097, 16385) + +HARNESS = r''' +#include "ed_sha512.h" +#include "ed_25519.h" +#include +#include +#include +#include +#include +#include +#include +#if defined(_WIN32) +#include +// PE linkers resolve the library's unused key-generation references before +// discarding them. Verification and seeded signing must never call this API. +RNGClass::RNGClass() {} +RNGClass::~RNGClass() {} +void RNGClass::rand(uint8_t*, size_t) { std::abort(); } +RNGClass RNG; +#endif + +static void printHex(const unsigned char* bytes, size_t length) { + for (size_t i = 0; i < length; ++i) printf("%02x", bytes[i]); + putchar('\n'); +} +static const unsigned char* bytes(const std::vector& data) { + static const unsigned char empty = 0; + return data.empty() ? &empty : data.data(); +} +static std::vector message(size_t length, unsigned salt) { + std::vector result(length); + for (size_t i = 0; i < length; ++i) result[i] = (i * 73 + salt) & 255; + return result; +} +static void decode(unsigned char* output, size_t length, const char* hex) { + assert(strlen(hex) == length * 2); + for (size_t i = 0; i < length; ++i) { + unsigned value; + assert(sscanf(hex + i * 2, "%2x", &value) == 1); + output[i] = value; + } +} +static void verifySignature(unsigned char* signature, const unsigned char* key, + const unsigned char* data, size_t length) { + // Retain the production C++ verification path; do not use the bundled + // C verifier which Identity deliberately excludes. + assert(Ed25519::verify(signature, key, data, length)); + for (size_t index : {size_t(0), size_t(17), size_t(32), size_t(63)}) { + signature[index] ^= 1; + assert(!Ed25519::verify(signature, key, data, length)); + signature[index] ^= 1; + } + std::vector changed(data, data + length); + if (changed.empty()) changed.push_back(1); else changed[0] ^= 1; + assert(!Ed25519::verify(signature, key, bytes(changed), changed.size())); + unsigned char wrong_key[32]; + memcpy(wrong_key, key, 32); wrong_key[0] ^= 1; + assert(!Ed25519::verify(signature, wrong_key, data, length)); +} +int main() { + const size_t lengths[] = {@LENGTHS@}; + const size_t chunks[] = {1, 7, 63, 64, 111, 112, 127, 128, 129, 257, 1024}; + unsigned char result[64]; + assert(sha512_init(nullptr) == 1); + assert(sha512_update(nullptr, result, 0) == 1); + assert(sha512_final(nullptr, result) == 1); + assert(sha512(nullptr, 0, result) == 1); + assert(sha512(result, 0, nullptr) == 1); + for (size_t length : lengths) { + const auto first = message(length, 19), second = message(length + 137, 91); + assert(sha512(bytes(first), first.size(), result) == 0); + printHex(result, sizeof(result)); + sha512_context contexts[2]; + size_t positions[2] = {}, turn = 0; + for (auto& context : contexts) { + assert(sha512_init(&context) == 0); + assert(sha512_update(&context, nullptr, 0) == 1); + assert(sha512_final(&context, nullptr) == 1); + assert(sha512_update(&context, result, 0) == 0); + } + while (positions[0] < first.size() || positions[1] < second.size()) { + for (unsigned i = 0; i < 2; ++i) { + const auto& data = i ? second : first; + size_t count = chunks[turn++ % (sizeof(chunks) / sizeof(chunks[0]))]; + if (count > data.size() - positions[i]) count = data.size() - positions[i]; + assert(sha512_update(&contexts[i], bytes(data) + positions[i], count) == 0); + positions[i] += count; + } + } + for (auto& context : contexts) { + assert(sha512_final(&context, result) == 0); + printHex(result, sizeof(result)); + // Restart a completed context and reset an unfinished one. Both + // must discard the old message without touching another context. + assert(sha512_init(&context) == 0); + assert(sha512_update(&context, bytes(second), second.size()) == 0); + assert(sha512_init(&context) == 0); + assert(sha512_update(&context, bytes(first), first.size()) == 0); + assert(sha512_final(&context, result) == 0); + unsigned char expected[64]; + assert(sha512(bytes(first), first.size(), expected) == 0); + assert(!memcmp(result, expected, 64)); + } + } + struct Vector { const char* seed; const char* key; const char* data; const char* signature; }; + // RFC 8032 section 7.1, tests 1-3. + // https://www.rfc-editor.org/rfc/rfc8032.html#section-7.1 + const Vector vectors[] = { + {"9d61b19deffd5a60ba844af492ec2cc44449c5697b326919703bac031cae7f60", + "d75a980182b10ab7d54bfed3c964073a0ee172f3daa62325af021a68f707511a", "", + "e5564300c360ac729086e2cc806e828a84877f1eb8e5d974d873e065224901555f" + "b8821590a33bacc61e39701cf9b46bd25bf5f0595bbe24655141438e7a100b"}, + {"4ccd089b28ff96da9db6c346ec114e0f5b8a319f35aba624da8cf6ed4fb8a6fb", + "3d4017c3e843895a92b70aa74d1b7ebc9c982ccf2ec4968cc0cd55f12af4660c", "72", + "92a009a9f0d4cab8720e820b5f642540a2b27b5416503f8fb3762223ebdb69da085" + "ac1e43e15996e458f3613d0f11d8c387b2eaeb4302aeeb00d291612bb0c00"}, + {"c5aa8df43f9f837bedb7442f31dcb7b166d38535076f094b85ce3a2e0b4458f7", + "fc51cd8e6218a1a38da47ed00230f0580816ed13ba3303ac5deb911548908025", "af82", + "6291d657deec24024827e69c3abe01a30ce548a284743a445e3680d7db5ac3ac" + "18ff9b538d16f290ae67f760984dc6594a7c15e9716ed28dc027beceea1ec40a"} + }; + for (const auto& vector : vectors) { + unsigned char seed[32], key[32], expected_key[32], secret[64], signature[64], expected[64]; + std::vector data(strlen(vector.data) / 2); + decode(seed, 32, vector.seed); decode(expected_key, 32, vector.key); + decode(expected, 64, vector.signature); + if (!data.empty()) decode(data.data(), data.size(), vector.data); + ed25519_create_keypair(key, secret, seed); + assert(!memcmp(key, expected_key, 32)); + ed25519_sign(signature, bytes(data), data.size(), key, secret); + assert(!memcmp(signature, expected, 64)); + verifySignature(signature, key, bytes(data), data.size()); + } + // Cross-backend byte-for-byte comparison covers long, irregular messages + // and existing expanded private-key storage, beyond the short RFC cases. + for (size_t length : lengths) { + unsigned char seed[32], key[32], secret[64], signature[64]; + const auto data = message(length, 37); + for (unsigned i = 0; i < 32; ++i) seed[i] = (length + i * 17) & 255; + ed25519_create_keypair(key, secret, seed); + ed25519_sign(signature, bytes(data), data.size(), key, secret); + verifySignature(signature, key, bytes(data), data.size()); + printHex(key, 32); printHex(secret, 64); printHex(signature, 64); + } + puts("Shared SHA512 and RFC8032 checks passed"); +} +''' + + +class SharedSHA512Test(unittest.TestCase): + def test_c_api_hashes_and_signatures_match_original_and_rfc8032(self): + compiler = shutil.which("gcc") + cpp = shutil.which("g++") + self.assertIsNotNone(compiler, "C compiler required") + self.assertIsNotNone(cpp, "C++ compiler required") + override = os.environ.get("MESHCORE_CRYPTO_DIR") + candidates = [Path(override)] if override else sorted((ROOT / ".pio/libdeps").glob("*/Crypto")) + names = ("SHA512.cpp", "Hash.cpp", "Crypto.cpp", "Ed25519.cpp", "Curve25519.cpp", + "BigNumberUtil.cpp") + crypto = next((path for path in candidates if all((path / name).is_file() for name in names)), None) + self.assertIsNotNone(crypto, "Install rweather/Crypto or set MESHCORE_CRYPTO_DIR (no crypto mocks)") + flags = ["-O1", "-g", "-Wall", "-Wextra", "-Werror", "-DHOST_BUILD=1", + "-DED25519_NO_SEED=1", "-DED25519_SMALL_BASE=1", + "-DED25519_COMPACT_SHA512=1", "-DED25519_COMPACT_SCALAR=1", + "-DED25519_COMPACT_FIELD=1", "-ffunction-sections", "-fdata-sections", + "-I" + str(ED25519), "-I" + str(crypto)] + if os.name != "nt": + flags += ["-fsanitize=address,undefined", "-fno-sanitize-recover=all", "-fno-pie", "-no-pie"] + with tempfile.TemporaryDirectory(prefix="ed25519-shared-sha512-") as directory: + work = Path(directory) + if os.name == "nt": + # Crypto's HOST_BUILD expects the POSIX byte-order header. + # MinGW supplies the same conversions as compiler intrinsics. + endian = "#pragma once\n#include \n" + endian += "static_assert(__BYTE_ORDER__ == __ORDER_LITTLE_ENDIAN__, \"host byte order\");\n" + for bits in (16, 32, 64): + for name in (f"htobe{bits}", f"be{bits}toh"): + endian += f"static inline uint{bits}_t {name}(uint{bits}_t n) {{ return __builtin_bswap{bits}(n); }}\n" + for name in (f"htole{bits}", f"le{bits}toh"): + endian += f"static inline uint{bits}_t {name}(uint{bits}_t n) {{ return n; }}\n" + (work / "endian.h").write_text(endian) + flags += ["-I" + str(work)] + harness = work / "harness.cpp" + source = HARNESS.replace("@LENGTHS@", ",".join(map(str, LENGTHS))) + harness.write_text(source) + outputs = [] + for shared in (0, 1): + with self.subTest(shared=shared): + build_flags = flags + [f"-DED25519_USE_CRYPTO_SHA512={shared}"] + objects = [] + for name in ("fe.c", "ge.c", "keypair.c", "sc.c", "sha512.c", "sign.c"): + obj = work / (name + f"-{shared}.o") + # Legacy ref10 carries shift negative signed values. + # Keep ASan and all other UBSan checks on those files; + # the new adapter and both hash implementations retain + # the complete sanitizer set. + legacy = ["-fno-sanitize=shift"] if os.name != "nt" and name in ("fe.c", "ge.c") else [] + built = subprocess.run([compiler, "-std=c99", *build_flags, *legacy, "-c", str(ED25519 / name), + "-o", str(obj)], capture_output=True, text=True, timeout=60) + self.assertEqual(built.returncode, 0, built.stdout + built.stderr) + objects.append(str(obj)) + binary = work / f"shared-{shared}" + built = subprocess.run([cpp, "-std=c++11", *build_flags, str(harness), + str(ED25519 / "ed_sha512_crypto.cpp"), + *[str(crypto / name) for name in names], *objects, + "-Wl,--gc-sections", "-o", str(binary)], + capture_output=True, text=True, timeout=90) + self.assertEqual(built.returncode, 0, built.stdout + built.stderr) + run = subprocess.run([str(binary)], capture_output=True, text=True, timeout=60) + self.assertEqual(run.returncode, 0, run.stdout + run.stderr) + lines = run.stdout.splitlines() + expected = [] + for length in LENGTHS: + first = bytes((i * 73 + 19) & 255 for i in range(length)) + second = bytes((i * 73 + 91) & 255 for i in range(length + 137)) + expected += [hashlib.sha512(first).hexdigest()] * 2 + expected += [hashlib.sha512(second).hexdigest()] + self.assertEqual(lines[:len(expected)], expected) + self.assertEqual(lines[-1], "Shared SHA512 and RFC8032 checks passed") + outputs.append(lines) + self.assertEqual(len(outputs), 2) + self.assertEqual(outputs[0], outputs[1]) + + +if __name__ == "__main__": + unittest.main() diff --git a/test/test_esp32_tinyusb_cooperative_output.py b/test/test_esp32_tinyusb_cooperative_output.py index f0eccc0d8..2f83816d8 100644 --- a/test/test_esp32_tinyusb_cooperative_output.py +++ b/test/test_esp32_tinyusb_cooperative_output.py @@ -401,22 +401,40 @@ RECENT_TEST = r''' class CooperativeOutputTest(unittest.TestCase): def role_methods(self, text, role): - dump_start = text.index("void MyMesh::dumpLogFile()") + signature = "void MyMesh::dumpLogFile()" + dump = body(text, signature) + dump_start = text.index(dump) guard = "#if MESH_ENABLE_PACKET_FILE_LOGGING\n" if text[:dump_start].endswith(guard): - dump_start -= len(guard) - dump_end = text.index("\n#if MESH_USB_CONSOLE_COOPERATIVE\nbool MyMesh::hasPendingSerialOutput", dump_start) - methods = text[dump_start:dump_end] + "\n" + "\n".join(body(text, signature) for signature in ( - "bool MyMesh::hasPendingSerialOutput()", "void MyMesh::cancelPendingSerialOutput()", + active_end = dump_start + len(dump) + fallback = body(text[active_end:], signature) + fallback_start = text.index(fallback, active_end) + self.assertEqual(text[active_end:fallback_start].strip(), "#else") + fallback_end = fallback_start + len(fallback) + dump_end = text.index("\n#endif", fallback_end) + len("\n#endif") + self.assertEqual(text[fallback_end:dump_end].strip(), "#endif") + dump = text[dump_start - len(guard):dump_end] + methods = dump + "\n" + "\n".join(body(text, signature) for signature in ( + "void MyMesh::printAclSerial()", "bool MyMesh::hasPendingSerialOutput()", + "void MyMesh::cancelPendingSerialOutput()", "void MyMesh::servicePendingSerialOutput()")) if role == "simple_repeater": - format_start = text.index("static void formatLocalSnrX4(") - format_end = text.index("\nvoid MyMesh::formatRecentRepeatersReply", format_start) - recent_start = text.index("void MyMesh::printRecentRepeatersSerial()") - recent_end = text.index("\nbool MyMesh::setRecentRepeater(", recent_start) - methods = text[format_start:format_end] + "\n" + text[recent_start:recent_end] + "\n" + methods + methods = "\n".join(body(text, signature) for signature in ( + "static void formatLocalSnrX4(", "void MyMesh::printRecentRepeatersSerial()" + )) + "\n" + methods return methods + def test_role_method_extraction_ignores_adjacent_definitions(self): + for role in ("simple_repeater", "simple_room_server"): + with self.subTest(role=role): + text = (ROOT / f"examples/{role}/MyMesh.cpp").read_text() + text = text.replace("void MyMesh::printAclSerial()", + "bool MyMesh::unrelatedCommand() { return true; }\n\n" + "void MyMesh::printAclSerial()", 1) + methods = self.role_methods(text, role) + self.assertNotIn("unrelatedCommand", methods) + self.assertNotIn("handleGuestCommand", methods) + def test_usb_acl_commands_route_to_the_cooperative_pump(self): for role in ("simple_repeater", "simple_room_server"): with self.subTest(role=role):