diff --git a/.clusterfuzzlite/Dockerfile b/.clusterfuzzlite/Dockerfile index 86de4f5db..6c274def6 100644 --- a/.clusterfuzzlite/Dockerfile +++ b/.clusterfuzzlite/Dockerfile @@ -4,19 +4,16 @@ # As this is not a long running service, health-checks are not required. ClusterFuzzLite # also only works if the user remains unchanged from the base image (it expects to run # as root). -# trunk-ignore-all(trivy/DS026): No healthcheck is needed for this builder container +# trunk-ignore-all(trivy/DS-0026): No healthcheck is needed for this builder container # trunk-ignore-all(checkov/CKV_DOCKER_2): No healthcheck is needed for this builder container # trunk-ignore-all(checkov/CKV_DOCKER_3): We must run as root for this container -# trunk-ignore-all(trivy/DS002): We must run as root for this container -# trunk-ignore-all(checkov/CKV_DOCKER_8): We must run as root for this container -# trunk-ignore-all(hadolint/DL3002): We must run as root for this container +# trunk-ignore-all(trivy/DS-0002): We must run as root for this container FROM gcr.io/oss-fuzz-base/base-builder:v1 ENV PIP_ROOT_USER_ACTION=ignore # trunk-ignore(hadolint/DL3008): apt packages are not pinned. -# trunk-ignore(terrascan/AC_DOCKER_0002): apt packages are not pinned. RUN apt-get update && apt-get install --no-install-recommends -y \ cmake git zip libgpiod-dev libjsoncpp-dev libbluetooth-dev libi2c-dev \ libunistring-dev libmicrohttpd-dev libgnutls28-dev libgcrypt20-dev \ diff --git a/.coderabbit.yaml b/.coderabbit.yaml index 83f59cca0..a193662cb 100644 --- a/.coderabbit.yaml +++ b/.coderabbit.yaml @@ -13,7 +13,7 @@ reviews: drafts: false # Review PRs regardless of target branch. base_branches: - - ".*" + - .* # Skip Renovate dependency updates - CI gates dependencies; we review for substance, not every bump. ignore_usernames: - renovate @@ -27,7 +27,7 @@ reviews: - "!protobufs/**" path_instructions: - - path: "bin/config.d/**" + - path: bin/config.d/** instructions: > meshtasticd configuration files. Bundled with meshtasticd Linux/MacOS packaging. Ensure configurations include metadata found in other configs. diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile index d10db534f..acc4442ed 100644 --- a/.devcontainer/Dockerfile +++ b/.devcontainer/Dockerfile @@ -1,4 +1,3 @@ -# trunk-ignore-all(terrascan/AC_DOCKER_0002): Known terrascan issue # trunk-ignore-all(hadolint/DL3008): Do not pin apt package versions # trunk-ignore-all(hadolint/DL3013): Do not pin pip package versions FROM mcr.microsoft.com/devcontainers/cpp:2-debian-13 diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index a631832cb..b66b858b7 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -8,7 +8,7 @@ "features": { "ghcr.io/devcontainers/features/python:1": { "installTools": true, - "version": "3.13" + "version": "3.14" } }, "customizations": { diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index f0c456f4b..0960d45c4 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -677,6 +677,8 @@ Unit tests in `test/` directory. The canonical suite count is in `test/native-su - `test_radio/` - Radio interface - `test_rtc/` - RTC / time handling - `test_serial/` - Serial communication +- `test_tak_config/` - TAK (ATAK) team/role value fidelity through set/save/load/get +- `test_module_config/` - every ModuleConfig submessage survives admin set -> save -> load -> get - `test_traffic_management/` - Traffic management (dedup, rate-limit, hop-trim, role exceptions) - `test_transmit_history/` - Retransmission tracking - `test_type_conversions/` - NodeDB v25 type conversion (bitfield round-trips, NodeInfoLite) diff --git a/.github/workflows/build_macos_bin.yml b/.github/workflows/build_macos_bin.yml index ccd649318..ed7c601b6 100644 --- a/.github/workflows/build_macos_bin.yml +++ b/.github/workflows/build_macos_bin.yml @@ -14,30 +14,71 @@ permissions: jobs: build-MacOS: runs-on: macos-${{ inputs.macos_ver }} + # Only pushes to the default branch (develop) populate the cache; PR / merge_group runs + # restore it but never save, so they stop filling up the repo's Actions cache storage. + env: + SAVE_CACHE: ${{ github.event_name == 'push' && github.ref_name == github.event.repository.default_branch }} steps: - name: Checkout code uses: actions/checkout@v7 with: submodules: recursive - - name: Install deps + - name: Capture runner image version (for caching) + id: r_image + run: echo "ver=$ImageVersion" >> "$GITHUB_OUTPUT" + + - name: Restore Homebrew cache + id: brew-cache + uses: actions/cache/restore@v6 + with: + path: ~/Library/Caches/Homebrew + key: brew-macos-${{ inputs.macos_ver }}-${{ steps.r_image.outputs.ver }} + + - name: Install deps (Homebrew) shell: bash - run: | - brew update - brew install platformio yaml-cpp libuv openssl@3 libusb argp-standalone pkg-config ulfius + env: + # GitHub-Hosted MacOS runner images are updated frequently + # so skip running `brew update` to avoid unnecessary failures and delays. + HOMEBREW_NO_AUTO_UPDATE: "1" + HOMEBREW_NO_INSTALL_CLEANUP: "1" + run: > + brew install + platformio yaml-cpp libuv openssl@3 libusb argp-standalone pkg-config ulfius jsoncpp + + - name: Save Homebrew cache + if: env.SAVE_CACHE == 'true' && steps.brew-cache.outputs.cache-hit != 'true' + uses: actions/cache/save@v6 + with: + path: ~/Library/Caches/Homebrew + key: brew-macos-${{ inputs.macos_ver }}-${{ steps.r_image.outputs.ver }} - name: Get release version string run: | echo "long=$(./bin/buildinfo.py long)" >> $GITHUB_OUTPUT id: version + - name: Restore PlatformIO cache + id: pio-cache + uses: actions/cache/restore@v6 + with: + path: ~/.platformio/.cache + key: pio-macos-${{ inputs.macos_ver }}-${{ hashFiles('platformio.ini', 'variants/native/portduino.ini', 'variants/native/portduino/platformio.ini') }} + restore-keys: | + pio-macos-${{ inputs.macos_ver }}- + - name: Build for MacOS run: | platformio run -e native-macos env: PKG_VERSION: ${{ steps.version.outputs.long }} - # Errors in this step should not fail the entire workflow while MacOS support is in development. - continue-on-error: true + + - name: Save PlatformIO cache + if: env.SAVE_CACHE == 'true' && steps.pio-cache.outputs.cache-hit != 'true' + uses: actions/cache/save@v6 + with: + path: ~/.platformio/.cache + key: pio-macos-${{ inputs.macos_ver }}-${{ hashFiles('platformio.ini', 'variants/native/portduino.ini', 'variants/native/portduino/platformio.ini') }} - name: List output files run: ls -lah .pio/build/native-macos/ diff --git a/.github/workflows/build_portduino_wasm.yml b/.github/workflows/build_portduino_wasm.yml index c6c32583a..3afe62123 100644 --- a/.github/workflows/build_portduino_wasm.yml +++ b/.github/workflows/build_portduino_wasm.yml @@ -18,6 +18,10 @@ jobs: build-wasm: name: Build PortDuino WASM runs-on: ubuntu-latest + # Only pushes to the default branch (develop) populate the cache; PR / merge_group runs + # restore it but never save, so they stop filling up the repo's Actions cache storage. + env: + SAVE_CACHE: ${{ github.event_name == 'push' && github.ref_name == github.event.repository.default_branch }} steps: - uses: actions/checkout@v7 with: @@ -29,14 +33,30 @@ jobs: uses: ./.github/actions/setup-native - name: Setup Emscripten SDK - uses: emscripten-core/setup-emsdk@v16 + uses: emscripten-core/setup-emsdk@0822153d7a5488b70a269cfa0a631b2a86ab4da2 # 'v17' main with: version: 6.0.1 actions-cache-folder: emsdk-cache + - name: Restore PlatformIO cache + id: pio-cache + uses: actions/cache/restore@v6 + with: + path: ~/.platformio/.cache + key: pio-wasm-${{ hashFiles('platformio.ini', 'variants/native/portduino.ini', 'variants/native/portduino/platformio.ini') }} + restore-keys: | + pio-wasm- + - name: Build PortDuino WASM run: platformio run -e native-wasm + - name: Save PlatformIO cache + if: env.SAVE_CACHE == 'true' && steps.pio-cache.outputs.cache-hit != 'true' + uses: actions/cache/save@v6 + with: + path: ~/.platformio/.cache + key: pio-wasm-${{ hashFiles('platformio.ini', 'variants/native/portduino.ini', 'variants/native/portduino/platformio.ini') }} + - name: Assert WASM artifacts run: | OUT=.pio/build/native-wasm diff --git a/.github/workflows/build_windows_bin.yml b/.github/workflows/build_windows_bin.yml new file mode 100644 index 000000000..5348a1163 --- /dev/null +++ b/.github/workflows/build_windows_bin.yml @@ -0,0 +1,142 @@ +name: Build Windows Binary + +on: + workflow_call: + inputs: + windows_ver: + required: false + default: "2025" + type: string + +permissions: + contents: read + +jobs: + build-Windows: + runs-on: windows-${{ inputs.windows_ver }} + # Only pushes to the default branch (develop) populate the cache; PR / merge_group runs + # restore it but never save, so they stop filling up the repo's Actions cache storage. + env: + SAVE_CACHE: ${{ github.event_name == 'push' && github.ref_name == github.event.repository.default_branch }} + defaults: + run: + # UCRT64 is the MinGW-w64 environment native-windows targets; the `msys` + # environment would link msys-2.0.dll and produce a Cygwin-style binary. + shell: msys2 {0} + steps: + - name: Checkout code + uses: actions/checkout@v7 + with: + submodules: recursive + # Keep the token out of .git/config so later steps and artifacts can't leak it. + persist-credentials: false + + - name: Setup MSYS2 / UCRT64 + id: msys2 + uses: msys2/setup-msys2@v2 + with: + msystem: UCRT64 + update: true + cache: true + # argp is not packaged for the mingw environments and is built below. + # Python is omitted too: MSYS2's reports a `mingw` platform tag no wheel matches. + install: >- + mingw-w64-ucrt-x86_64-gcc + mingw-w64-ucrt-x86_64-pkgconf + mingw-w64-ucrt-x86_64-yaml-cpp + mingw-w64-ucrt-x86_64-libuv + mingw-w64-ucrt-x86_64-jsoncpp + mingw-w64-ucrt-x86_64-openssl + mingw-w64-ucrt-x86_64-libusb + mingw-w64-ucrt-x86_64-cmake + mingw-w64-ucrt-x86_64-ninja + git + + - name: Setup Python + uses: actions/setup-python@v6 + with: + python-version: "3.14" + cache: pip + + # framework-portduino calls argp_parse(); MSYS2 packages argp only for the + # msys runtime, which cannot link into a native binary. No install() rules. + - name: Build and install argp-standalone + run: | + git clone --depth 1 https://github.com/tom42/argp-standalone /tmp/argp + cd /tmp/argp + cmake -G Ninja -B build -DCMAKE_BUILD_TYPE=Release . + cmake --build build + cp include/argp-standalone/argp.h /ucrt64/include/argp.h + cp build/src/libargp-standalone.a /ucrt64/lib/libargp.a + + - name: Install PlatformIO + shell: pwsh + run: | + python -m pip install --upgrade pip + pip install platformio + + - name: Restore PlatformIO cache + id: pio-cache + uses: actions/cache/restore@v6 + with: + path: ~/.platformio/.cache + key: pio-windows-${{ inputs.windows_ver }}-${{ hashFiles('platformio.ini', 'variants/native/portduino.ini', 'variants/native/portduino/platformio.ini') }} + restore-keys: | + pio-windows-${{ inputs.windows_ver }}- + + - name: Get release version string + shell: pwsh + id: version + run: echo "long=$(python ./bin/buildinfo.py long)" >> $env:GITHUB_OUTPUT + + # Runs outside the MSYS2 shell so PlatformIO stays on the runner's + # CPython; the UCRT64 toolchain is reached through PATH instead. + - name: Build for Windows + shell: pwsh + run: | + $env:PATH = "${{ steps.msys2.outputs.msys2-location }}\ucrt64\bin;$env:PATH" + platformio run -e native-windows + env: + PKG_VERSION: ${{ steps.version.outputs.long }} + + - name: Save PlatformIO cache + if: env.SAVE_CACHE == 'true' && steps.pio-cache.outputs.cache-hit != 'true' + uses: actions/cache/save@v6 + with: + path: ~/.platformio/.cache + key: pio-windows-${{ inputs.windows_ver }}-${{ hashFiles('platformio.ini', 'variants/native/portduino.ini', 'variants/native/portduino/platformio.ini') }} + + - name: List output files + run: ls -lah .pio/build/native-windows/ + + # The env links statically, so only Windows system DLLs should appear here. + # A third-party DLL means the static link regressed. + - name: Verify the binary is self-contained + run: | + set -euo pipefail + bin=.pio/build/native-windows/meshtasticd.exe + test -f "$bin" + # `|| true` keeps grep's no-match exit out of `set -e`; an empty import + # table is caught by the test below instead of passing as "no deps". + deps=$(objdump -p "$bin" | grep -i 'DLL Name' || true) + test -n "$deps" + extra=$(printf '%s\n' "$deps" \ + | grep -viE 'api-ms-win|KERNEL32|WS2_32|ADVAPI32|USER32|msvcrt|ucrtbase|bcrypt|IPHLPAPI|SHELL32|ole32|CRYPT32|SETUPAPI|CFGMGR32|WINMM|dbghelp' || true) + if [ -n "$extra" ]; then + printf '%s\n' "$extra" + echo "::error::meshtasticd.exe has non-system DLL dependencies (static link regressed)" + exit 1 + fi + echo "OK: no third-party DLL dependencies" + + - name: Smoke test the binary + run: | + .pio/build/native-windows/meshtasticd.exe --version + + - name: Store binaries as an artifact + uses: actions/upload-artifact@v7 + with: + name: firmware-windows-${{ inputs.windows_ver }}-${{ steps.version.outputs.long }} + overwrite: true + path: | + .pio/build/native-windows/meshtasticd.exe diff --git a/.github/workflows/docker_build.yml b/.github/workflows/docker_build.yml index 03a1f91cd..8c16e75aa 100644 --- a/.github/workflows/docker_build.yml +++ b/.github/workflows/docker_build.yml @@ -45,6 +45,10 @@ jobs: outputs: digest: ${{ steps.docker_variant.outputs.digest }} runs-on: ${{ inputs.runs-on }} + env: + # Only cache on default branch (develop). + # 'docker/' actions don't support separate Restore/Save actions, so we have to disable caching entirely on PRs/merge_queue. + USE_CACHE: ${{ github.ref_name == github.event.repository.default_branch }} steps: - name: Checkout code uses: actions/checkout@v7 @@ -59,11 +63,12 @@ jobs: - name: Set up QEMU uses: docker/setup-qemu-action@v4 with: - cache-image: true + cache-image: ${{ env.USE_CACHE }} - name: Docker setup uses: docker/setup-buildx-action@v4 with: + cache-binary: ${{ env.USE_CACHE }} # Add Google/Amazon DockerHub mirrors to buildkit config # https://docs.docker.com/build/ci/github-actions/configure-builder/#registry-mirror buildkitd-config-inline: | @@ -105,6 +110,6 @@ jobs: platforms: ${{ inputs.platform }} build-args: | PIO_ENV=${{ inputs.pio_env }} - # Cache image layers in GitHub Actions cache to speed up subsequent builds. - cache-from: type=gha - cache-to: type=gha,mode=max + # Disabled for now: Cache image layers in GitHub Actions cache to speed up subsequent builds. + # cache-from: type=gha + # cache-to: type=gha,mode=max diff --git a/.github/workflows/main_matrix.yml b/.github/workflows/main_matrix.yml index a00976973..c3c035244 100644 --- a/.github/workflows/main_matrix.yml +++ b/.github/workflows/main_matrix.yml @@ -3,12 +3,21 @@ concurrency: group: ci-${{ github.head_ref || github.run_id }} cancel-in-progress: true on: - # # Triggers the workflow on push but only for the main branches + # The merge queue is the pre-merge gate for the protected branches (master, + # develop): a merge_group run validates the merged result before code lands. For + # now PRs and merge_group runs build the same narrowed board subset (--level pr); + # see the setup job. push still runs the full matrix on master/develop (post-merge) + # as well as on the event/* and feature/* branches, which have no merge queue. + merge_group: + types: [checks_requested] + branches: + - master + - develop + push: branches: - master - develop - - pioarduino # Remove when merged // use `feature/` in the future. - event/* - feature/* paths-ignore: @@ -19,7 +28,6 @@ on: branches: - master - develop - - pioarduino # Remove when merged // use `feature/` in the future. - event/* - feature/* paths-ignore: @@ -28,9 +36,9 @@ on: schedule: # Nightly develop build, published to meshtastic.github.io firmware-nightly/ (no GitHub release). - # Scheduled runs execute on the default branch (develop). 09:00 UTC avoids the 00:00 tests + # Scheduled runs execute on the default branch (develop). 07:00 UTC avoids the 00:00 tests # and 02:00 daily_packaging crons. - - cron: 0 9 * * * # Nightly develop build/publish (default branch is develop) + - cron: 0 7 * * * # Nightly develop build/publish (default branch is develop) workflow_dispatch: inputs: @@ -61,10 +69,12 @@ jobs: - name: Generate matrix id: jsonStep run: | - if [[ "$GITHUB_HEAD_REF" == "" ]]; then - TARGETS=$(./bin/generate_ci_matrix.py ${{matrix.arch}}) - else + # PRs and (for now) merge_group builds use the narrowed --level pr board + # subset. Full-matrix builds run on push / schedule / workflow_dispatch. + if [[ "$GITHUB_EVENT_NAME" == "pull_request" || "$GITHUB_EVENT_NAME" == "merge_group" ]]; then TARGETS=$(./bin/generate_ci_matrix.py ${{matrix.arch}} --level pr) + else + TARGETS=$(./bin/generate_ci_matrix.py ${{matrix.arch}}) fi echo "Name: $GITHUB_REF_NAME Base: $GITHUB_BASE_REF Ref: $GITHUB_REF" echo "${{matrix.arch}}=$TARGETS" >> $GITHUB_OUTPUT @@ -144,6 +154,18 @@ jobs: macos_ver: ${{ matrix.macos_ver }} # secrets: inherit + Windows: + if: ${{ github.event_name != 'schedule' && github.event.inputs.nightly != 'true' }} + strategy: + fail-fast: false + matrix: + windows_ver: + - "2025" # x86_64 + uses: ./.github/workflows/build_windows_bin.yml + with: + windows_ver: ${{ matrix.windows_ver }} + # secrets: inherit + package-pio-deps-native-tft: if: ${{ github.repository == 'meshtastic/firmware' && github.event_name == 'workflow_dispatch' }} uses: ./.github/workflows/package_pio_deps.yml @@ -177,15 +199,8 @@ jobs: fail-fast: false matrix: distro: [debian, alpine] - platform: [linux/amd64, linux/arm64, linux/arm/v7] - pio_env: [native, native-tft] - exclude: - - distro: alpine - platform: linux/arm/v7 - - pio_env: native-tft - platform: linux/arm64 - - pio_env: native-tft - platform: linux/arm/v7 + platform: [linux/arm64] + pio_env: [native-tft] uses: ./.github/workflows/docker_build.yml with: distro: ${{ matrix.distro }} @@ -195,7 +210,6 @@ jobs: push: false gather-artifacts: - # trunk-ignore(checkov/CKV2_GHA_1) if: github.repository == 'meshtastic/firmware' strategy: fail-fast: false diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml index f0a79e330..1cb1fd8e9 100644 --- a/.github/workflows/nightly.yml +++ b/.github/workflows/nightly.yml @@ -36,4 +36,4 @@ jobs: - name: Trunk Upgrade uses: trunk-io/trunk-action/upgrade@v1 with: - base: master + base: develop diff --git a/.github/workflows/pr_tests.yml b/.github/workflows/pr_tests.yml index 7d910154c..e21d2f857 100644 --- a/.github/workflows/pr_tests.yml +++ b/.github/workflows/pr_tests.yml @@ -4,10 +4,11 @@ name: Tests on: workflow_dispatch: inputs: + # trunk-ignore(checkov/CKV_GHA_7): the reason input is a free-text run label, never fed into the build reason: - description: "Reason for manual test run" + description: Reason for manual test run required: false - default: "Manual test execution" + default: Manual test execution concurrency: group: tests-${{ github.head_ref || github.run_id }} @@ -21,7 +22,7 @@ permissions: jobs: native-tests: - name: "๐Ÿงช Native Tests" + name: ๐Ÿงช Native Tests if: github.repository == 'meshtastic/firmware' uses: ./.github/workflows/test_native.yml permissions: @@ -30,7 +31,7 @@ jobs: checks: write test-summary: - name: "๐Ÿ“Š Test Results" + name: ๐Ÿ“Š Test Results runs-on: ubuntu-latest needs: [native-tests] if: always() diff --git a/.github/workflows/test_native.yml b/.github/workflows/test_native.yml index 3b1b95ee8..f41a91199 100644 --- a/.github/workflows/test_native.yml +++ b/.github/workflows/test_native.yml @@ -10,11 +10,56 @@ env: LCOV_CAPTURE_FLAGS: --quiet --capture --include "${PWD}/src/*" --exclude '*/src/mesh/generated/*' --directory .pio/build/coverage/src --base-directory "${PWD}" jobs: + # Guard the registered native-suite total. `platformio test` discovers and runs whatever + # test_* directories exist, so it never notices when test/native-suite-count drifts from the + # actual directory count (a suite added without registering it, or the file left stale). That + # reconciliation only lives in bin/run-tests.sh, which CI does not invoke - so mirror the exact + # check here and fail the PR on a mismatch, keeping the manual count honest. + suite-count-check: + name: Native Suite Count + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + persist-credentials: false + + - name: Reconcile native-suite-count with test/ directories + shell: bash + run: | + set -euo pipefail + count_file="test/native-suite-count" + if [[ ! -f $count_file ]]; then + echo "::error title=Missing native-suite-count::$count_file not found - it must record the number of test_* suite directories." + exit 1 + fi + # Same canonical set as bin/run-tests.sh: directories named test_* directly under test/. + expected_count=$(find test -maxdepth 1 -type d -name 'test_*' -printf '%f\n' | wc -l) + canonical_count=$(tr -d '[:space:]' <"$count_file") + if ! [[ $canonical_count =~ ^[0-9]+$ ]]; then + echo "::error title=Invalid native-suite-count::$count_file must contain a single integer, got '$canonical_count'." + exit 1 + fi + echo "test/ directories: $expected_count" + echo "native-suite-count: $canonical_count" + if [[ $expected_count -ne $canonical_count ]]; then + if [[ $expected_count -gt $canonical_count ]]; then + hint="a suite was added - bump $count_file to $expected_count" + else + hint="a suite was removed - lower $count_file to $expected_count" + fi + echo "::error title=native-suite-count mismatch::test/ has $expected_count suite directories but $count_file says $canonical_count ($hint)." + exit 1 + fi + echo "native-suite-count matches the $expected_count suite directories." + simulator-tests: name: Native Simulator Tests runs-on: ubuntu-latest + needs: suite-count-check steps: - - uses: actions/checkout@v7 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 with: submodules: recursive @@ -87,8 +132,9 @@ jobs: platformio-tests: name: Native PlatformIO Tests runs-on: ubuntu-latest + needs: suite-count-check steps: - - uses: actions/checkout@v7 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 with: submodules: recursive @@ -105,14 +151,90 @@ jobs: - name: Disable BUILD_EPOCH run: sed -i 's/-DBUILD_EPOCH=$UNIX_TIME/#-DBUILD_EPOCH=$UNIX_TIME/' platformio.ini - - name: PlatformIO Tests + - name: Build test programs once + # One shared build of src + every test program. This is the single source build; gcov then + # accumulates coverage counts into this shared .pio/build/coverage/src as the chunks run. + run: platformio test -e coverage --without-testing + + - name: Run tests one area at a time + shell: bash run: | - set -o pipefail - # Filter out SKIPPED summary rows for hardware variants that can't run on the - # native host. They flood the log and make it harder to spot real failures. - # The JUnit XML is written directly to testreport.xml before the pipe, so - # the test artifact is unaffected. - platformio test -e coverage -v --junit-output-path testreport.xml 2>&1 | grep -v "[[:space:]]SKIPPED$" + set -uo pipefail + # One runner, no matrix, no concurrency. Group the test_* suites by area and run each + # area sequentially, reusing the single build above (--without-building). Each area gets + # its own JUnit report and its own collapsible log, so a failure lands in a small named + # section instead of being buried past the log limit. Sequential runs share one build + # dir, so gcov coverage accumulates and the single capture in the next step has the union. + + # Ordered area rules "name:ERE"; first match wins. Anything unmatched falls to "misc", so + # a newly added suite always runs even before it is placed. Add a suite to an area by + # extending that area's regex; add a new area by inserting a rule line. + area_rules=( + "admin:^test_(admin|pki)_" + "crypto:^test_(crypto|packet_signing)$" + "routing:^test_(mesh|nexthop|traceroute|hop|traffic|nodedb|warm)_" + "position:^test_position_" + "fuzz:^test_fuzz_" + "packets:^test_(packet|transmit|meshpacket)_" + "io:^test_(serial|stream|xmodem|http|mqtt)" + ) + + mapfile -t suites < <(find test -maxdepth 1 -type d -name 'test_*' -printf '%f\n' | sort) + declare -A group + for s in "${suites[@]}"; do + a="misc" + for rule in "${area_rules[@]}"; do + if [[ "$s" =~ ${rule#*:} ]]; then a="${rule%%:*}"; break; fi + done + group[$a]="${group[$a]:-} -f $s" + done + + run_order=() + for rule in "${area_rules[@]}"; do run_order+=("${rule%%:*}"); done + run_order+=("misc") + + fail=0 + for a in "${run_order[@]}"; do + [ -n "${group[$a]:-}" ] || continue + echo "::group::area $a (${group[$a]# })" + # Capture platformio's real exit status (not grep's) via a log file, then show the log + # with the noisy per-variant SKIPPED rows filtered out. + if ! platformio test -e coverage --without-building -v ${group[$a]# } \ + --junit-output-path "testreport-$a.xml" > "area-$a.log" 2>&1; then + fail=1 + echo "::error::area $a had test failures" + fi + grep -v "[[:space:]]SKIPPED$" "area-$a.log" || true + echo "::endgroup::" + done + exit $fail + + - name: Merge per-area reports into testreport.xml + # Preserve the single-file JUnit contract that downstream consumers rely on + # (pr_tests.yml's summary and generate-reports' Test Report both read testreport.xml). + # The per-area split is only for readable logs; the report stays consolidated. + if: always() # run even when a chunk failed, so the report captures the failures + shell: bash + run: | + python3 - <<'PY' + import glob, xml.etree.ElementTree as ET + out = ET.Element('testsuites') + for f in sorted(glob.glob('testreport-*.xml')): + try: + root = ET.parse(f).getroot() + except ET.ParseError: + continue + # PlatformIO writes a root; fold in a bare too, just in case. + out.extend(root.findall('testsuite') if root.tag == 'testsuites' else [root]) + ET.ElementTree(out).write('testreport.xml', encoding='utf-8', xml_declaration=True) + PY + + - name: Capture coverage information + if: always() # run this step even if previous step failed + run: | + sudo apt-get install -y lcov + lcov ${{ env.LCOV_CAPTURE_FLAGS }} --test-name tests --output-file coverage_tests.info + sed -i -e "s#${PWD}#.#" coverage_tests.info # Make paths relative. - name: Save test results if: always() # run this step even if previous step failed @@ -122,13 +244,6 @@ jobs: overwrite: true path: ./testreport.xml - - name: Capture coverage information - if: always() # run this step even if previous step failed - run: | - sudo apt-get install -y lcov - lcov ${{ env.LCOV_CAPTURE_FLAGS }} --test-name tests --output-file coverage_tests.info - sed -i -e "s#${PWD}#.#" coverage_tests.info # Make paths relative. - - name: Save coverage information uses: actions/upload-artifact@v7 if: always() # run this step even if previous step failed @@ -149,7 +264,7 @@ jobs: - platformio-tests if: always() steps: - - uses: actions/checkout@v7 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 - name: Get release version string run: echo "long=$(./bin/buildinfo.py long)" >> $GITHUB_OUTPUT @@ -184,9 +299,17 @@ jobs: merge-multiple: true - name: Generate Code Coverage Report + # Merge every tracefile the jobs produced: coverage_base.info (zeroed baseline), + # coverage_integration.info, and one coverage_tests_.info per chunk. lcov + # sums hit counts across them, so the merged report is the union of all chunks - + # identical to running the whole suite in one job. run: | sudo apt-get install -y lcov - lcov --quiet --add-tracefile code-coverage-report/coverage_base.info --add-tracefile code-coverage-report/coverage_integration.info --add-tracefile code-coverage-report/coverage_tests.info --output-file code-coverage-report/coverage_src.info + args=() + for f in code-coverage-report/coverage_*.info; do + args+=(--add-tracefile "$f") + done + lcov --quiet "${args[@]}" --output-file code-coverage-report/coverage_src.info genhtml --quiet --legend --prefix "${PWD}" code-coverage-report/coverage_src.info --output-directory code-coverage-report - name: Save Code Coverage Report diff --git a/.github/workflows/trunk_annotate_pr.yml b/.github/workflows/trunk_annotate_pr.yml index 09a606cc8..4bfe27d63 100644 --- a/.github/workflows/trunk_annotate_pr.yml +++ b/.github/workflows/trunk_annotate_pr.yml @@ -24,3 +24,4 @@ jobs: uses: trunk-io/trunk-action@v1 with: post-annotations: true + cache: false diff --git a/.github/workflows/trunk_check.yml b/.github/workflows/trunk_check.yml index acdc8ee1e..278e8db61 100644 --- a/.github/workflows/trunk_check.yml +++ b/.github/workflows/trunk_check.yml @@ -22,3 +22,4 @@ jobs: uses: trunk-io/trunk-action@v1 with: save-annotations: true + cache: false diff --git a/.trunk/trunk.yaml b/.trunk/trunk.yaml index 9b0c760bb..7bec30998 100644 --- a/.trunk/trunk.yaml +++ b/.trunk/trunk.yaml @@ -102,6 +102,13 @@ lint: - linters: [gitleaks] paths: - test/fixtures/nodedb/seed_v25_*.jsonl + # checkov/CKV_SECRET_6 flags the commented-out "large4cats" example, + # which is the public default credential for the meshtastic.org MQTT + # broker rather than a real secret. Ignore the whole file so the + # example config stays free of lint-suppression comments. + - linters: [ALL] + paths: + - userPrefs.jsonc # The UA font's em/en dashes live only in trailing comments documenting # each glyph's codepoint (e.g. "8212=:2549"); rewriting them would make # those docs inaccurate. Glyph byte data is unaffected either way. diff --git a/Dockerfile b/Dockerfile index c7b48313e..a8d886569 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,5 @@ -# trunk-ignore-all(trivy/DS002): We must run as root for this container +# trunk-ignore-all(trivy/DS-0002): We must run as root for this container +# trunk-ignore-all(checkov/CKV_DOCKER_8): We must run as root for this container # trunk-ignore-all(hadolint/DL3002): We must run as root for this container # trunk-ignore-all(hadolint/DL3008): Do not pin apt package versions # trunk-ignore-all(hadolint/DL3013): Do not pin pip package versions diff --git a/alpine.Dockerfile b/alpine.Dockerfile index facc180b8..e82230d61 100644 --- a/alpine.Dockerfile +++ b/alpine.Dockerfile @@ -1,4 +1,5 @@ -# trunk-ignore-all(trivy/DS002): We must run as root for this container +# trunk-ignore-all(trivy/DS-0002): We must run as root for this container +# trunk-ignore-all(checkov/CKV_DOCKER_8): We must run as root for this container # trunk-ignore-all(hadolint/DL3002): We must run as root for this container # trunk-ignore-all(hadolint/DL3018): Do not pin apk package versions # trunk-ignore-all(hadolint/DL3013): Do not pin pip package versions diff --git a/bin/build-esp32.sh b/bin/build-esp32.sh index 4e799b30a..e7dede709 100755 --- a/bin/build-esp32.sh +++ b/bin/build-esp32.sh @@ -12,7 +12,8 @@ rm -f $OUTDIR/firmware* rm -r $OUTDIR/* || true # Important to pull latest version of libs into all device flavors, otherwise some devices might be stale -platformio pkg install -e $1 +# platformio pkg install -e $1 +# ...redundant with pioarduino echo "Building for $1 with $PLATFORMIO_BUILD_FLAGS" rm -f $BUILDDIR/firmware* diff --git a/bin/check-all.sh b/bin/check-all.sh index 9c7fc694d..3186899aa 100755 --- a/bin/check-all.sh +++ b/bin/check-all.sh @@ -1,26 +1,82 @@ #!/usr/bin/env bash -# Note: This is a prototype for how we could add static code analysis to the CI. +# Run cppcheck static analysis over one or more PlatformIO environments. +# +# Why this is more than a bare `pio check`: the CI `check` matrix job in main_matrix.yml runs this +# script inside meshtastic/gh-action-firmware, and `pio check` must download the platform package +# before it can analyse anything. That download is regularly reset mid-flight by the CDN, e.g. +# +# Checking station-g3 > cppcheck (board: station-g3; platform: .../platform-espressif32.zip) +# requests.exceptions.ConnectionError: ('Connection aborted.', ConnectionResetError(104, ...)) +# +# The analysis never starts, so there is no signal at all - just a red X someone has to re-run by +# hand. `pio check` exits 1 for that AND for real defects, so the exit code cannot tell them apart; +# only the output can. This script classifies the failure: +# +# * cppcheck produced results (summary table or defect lines) -> real, propagate the exit status. +# * transport-level network error and nothing else -> warn and skip. +# * anything else -> propagate the exit status. +# +# A missing or forbidden package (404 / UnknownPackageError / 403) is deliberately NOT treated as +# transient: that is a reproducible break from a bad pin in a .ini, not weather. +# +# Exit codes: 0 = clean (or skipped under CI), 1 = defects/error, 2 = skipped locally. -set -e +set -uo pipefail -VERSION=$(bin/buildinfo.py long) +VERSION=$(bin/buildinfo.py long) || exit 1 # The shell vars the build tool expects to find export APP_VERSION=$VERSION if [[ $# -gt 0 ]]; then # can override which environment by passing arg - BOARDS="$@" + BOARDS=("$@") else - BOARDS="tlora-v2 tlora-v1 tlora_v1_3 tlora-v2-1-1.6 tbeam heltec-v2.0 heltec-v2.1 tbeam0.7 meshtastic-diy-v1 rak4631 rak4631_eink rak11200 t-echo canaryone pca10059_diy_eink" + BOARDS=(tlora-v2 tlora-v1 tlora_v1_3 tlora-v2-1-1.6 tbeam heltec-v2.0 heltec-v2.1 tbeam0.7 meshtastic-diy-v1 rak4631 rak4631_eink rak11200 t-echo canaryone pca10059_diy_eink) fi -echo "BOARDS:${BOARDS}" +echo "BOARDS:${BOARDS[*]}" -CHECK="" -for BOARD in $BOARDS; do - CHECK="${CHECK} -e ${BOARD}" +# Array, not a string: board names reach pio as literal arguments, so an override containing +# whitespace or a glob character cannot turn into extra pio arguments. +CHECK=() +for BOARD in "${BOARDS[@]}"; do + CHECK+=(-e "${BOARD}") done -pio check --flags "-DAPP_VERSION=${APP_VERSION} --suppressions-list=suppressions.txt --inline-suppr" $CHECK --skip-packages --pattern="src/" --fail-on-defect=low --fail-on-defect=medium --fail-on-defect=high +LOG=$(mktemp) +trap 'rm -f "$LOG"' EXIT + +# Keep streaming to the console so the CI log reads exactly as it did before; tee a copy for the +# post-mortem classification below. +pio check --flags "-DAPP_VERSION=${APP_VERSION} --suppressions-list=suppressions.txt --inline-suppr" "${CHECK[@]}" --skip-packages --pattern="src/" --fail-on-defect=low --fail-on-defect=medium --fail-on-defect=high 2>&1 | tee "$LOG" +STATUS=${PIPESTATUS[0]} + +if [[ $STATUS -eq 0 ]]; then + exit 0 +fi + +# Fail closed: if cppcheck got far enough to report anything, the run is real no matter what other +# noise is in the log. +if grep -Eqi '^Component[[:space:]]+.*HIGH|^Total[[:space:]]+[0-9]|^[^[:space:]]+:[0-9]+: \[(high|medium|low)' "$LOG"; then + exit "$STATUS" +fi + +# Transport-level failures only. No 403/404/UnknownPackageError here, on purpose. +TRANSIENT='requests\.exceptions\.(ConnectionError|Timeout|ReadTimeout|ConnectTimeout)|ConnectionResetError|urllib3\.exceptions\.(ProtocolError|MaxRetryError|NameResolutionError|ReadTimeoutError)|Read timed out|Connection aborted|Connection refused|Temporary failure in name resolution|Could not resolve host|(429|500|502|503|504) (Server|Client) Error|Too Many Requests' + +if ! grep -Eq "$TRANSIENT" "$LOG"; then + exit "$STATUS" +fi + +REASON=$(grep -Em1 "$TRANSIENT" "$LOG" | tr -d '\r' | cut -c1-200) + +echo "RESULT: SKIPPED ${BOARDS[*]} - transient network failure: ${REASON}" + +if [[ ${GITHUB_ACTIONS:-false} == "true" ]]; then + echo "::warning title=Static analysis skipped (${BOARDS[*]})::pio check could not download its packages: ${REASON}" + exit 0 +fi + +exit 2 diff --git a/docs/lora_region_preset_compatibility_client_spec.md b/docs/lora_region_preset_compatibility_client_spec.md index d2debef3f..bb1749672 100644 --- a/docs/lora_region_preset_compatibility_client_spec.md +++ b/docs/lora_region_preset_compatibility_client_spec.md @@ -153,7 +153,10 @@ These rules are what keep the UX correct across firmware versions. Implement all 5. **EU region auto-swap caveat.** The firmware treats the EU sibling regions (`EU_868` / `EU_866` / `EU_N_868`) specially: if the user is in one of them and selects a preset that belongs to a sibling's list, the firmware **swaps the region** rather than - rejecting the preset. Consequence for clients: **do not assume the region is immutable + rejecting the preset. To make this visible in the picker, the firmware advertises the + **same superset** (the union of the trio's presets) for all three sibling regions, so a + client filtering per ยง6 will offer every EU 86x preset regardless of which sibling is + currently selected. Consequence for clients: **do not assume the region is immutable across a preset change** - after an admin config write, re-read the resulting `LoRaConfig` and reflect the (possibly changed) region back into the UI. @@ -249,12 +252,23 @@ so they are stable as listed here: | group_index | default_preset | licensed_only | presets | | ----------------------- | -------------- | ------------- | ---------------------------------------------------------------------------------------------------------------------------- | | 0 (standard) | `LONG_FAST` | false | LONG_FAST, LONG_SLOW, MEDIUM_SLOW, MEDIUM_FAST, SHORT_SLOW, SHORT_FAST, LONG_MODERATE, SHORT_TURBO, LONG_TURBO, MEDIUM_TURBO | -| 1 (EU 868) | `LONG_FAST` | false | LONG_FAST, LONG_SLOW, MEDIUM_SLOW, MEDIUM_FAST, SHORT_SLOW, SHORT_FAST, LONG_MODERATE | -| 2 (EU 866 SRD / "lite") | `LITE_FAST` | false | LITE_FAST, LITE_SLOW | -| 3 (EU 868 narrow) | `NARROW_SLOW` | false | NARROW_FAST, NARROW_SLOW | +| 1 (EU 868) | `LONG_FAST` | false | _EU 86x superset_ (see below) | +| 2 (EU 866 SRD / "lite") | `LITE_FAST` | false | _EU 86x superset_ (see below) | +| 3 (EU 868 narrow) | `NARROW_SLOW` | false | _EU 86x superset_ (see below) | | 4 (ham 20 kHz) | `TINY_FAST` | **true** | TINY_FAST, TINY_SLOW | | 5 (ham 100 kHz) | `NARROW_SLOW` | **true** | NARROW_FAST, NARROW_SLOW | +The **EU 86x superset** advertised by groups 1, 2 and 3 is the union of the trio's own +band presets, because the firmware auto-swaps region within the trio on preset selection +(ยง5), so any of these is a legal pick from any of the three regions: + +```text +LONG_FAST, LONG_SLOW, MEDIUM_SLOW, MEDIUM_FAST, SHORT_SLOW, SHORT_FAST, LONG_MODERATE, LITE_FAST, LITE_SLOW, NARROW_FAST, NARROW_SLOW +``` + +The three groups still differ by `default_preset` (`LONG_FAST` / `LITE_FAST` / `NARROW_SLOW`), +which is why they remain distinct groups despite sharing this preset list. + `region_groups` (region โ†’ group_index): | group | regions | @@ -266,9 +280,11 @@ so they are stable as listed here: | 4 | ITU1_2M, ITU2_2M, ITU3_2M | | 5 | ITU2_125CM | -> Note groups **3** and **5** carry the same preset list (NARROW\_\*) but are distinct groups -> because they differ in `licensed_only`. Decoders must key on the group, not on the preset -> list, to preserve the licensing flag. +> Note that several groups can carry overlapping preset lists but remain distinct: groups 1, +> 2 and 3 share the EU 86x superset yet differ in `default_preset`, and group **5** (ham +> 100 kHz) shares the `NARROW_*` presets with group 3 but differs in `licensed_only`. +> Decoders must key on the group, not on the preset list, to preserve `default_preset` and +> the licensing flag. > > Regions **absent** from the table (no constraint info; see ยง5.1): `EU_874`, `EU_917`, > `ITU1_70CM`, `ITU2_70CM`, `ITU3_70CM`. diff --git a/docs/node_info_stores.md b/docs/node_info_stores.md new file mode 100644 index 000000000..33b0c366d --- /dev/null +++ b/docs/node_info_stores.md @@ -0,0 +1,248 @@ +# NodeInfo stores: the base and extended databases + +This document is an overview of the node-identity and traffic-state databases that the +TrafficManagementModule (TMM) either owns or leans on. There are four stores in play, but +only three form the identity lookup chain: + +1. **NodeDB hot store** - the authoritative `NodeInfoLite` array (identity tier 1). +2. **Warm tier** (`WarmNodeStore`) - minimal persisted records for hot-store evictees + (identity tier 2). +3. **TMM NodeInfo payload cache** (extended) - the ephemeral **third identity tier**: full + `User` payloads plus direct-response metadata; PSRAM-backed on hardware, plain heap in + native tests. + +The fourth store, the **TMM unified cache** (base - flat 10-byte-per-node traffic-shaping +state), is not part of that chain: it sits beside it, keyed by the same NodeNum, and only +its 4-bit cached role acts as a final fallback when all three identity tiers miss. + +Sources of truth: `src/mesh/NodeDB.{h,cpp}`, `src/mesh/WarmNodeStore.h`, +`src/modules/TrafficManagementModule.{h,cpp}`, sizing in `src/mesh/mesh-pb-constants.h`. + +--- + +## 1. NodeDB hot store (authoritative) + +- **What:** the classic `meshNodes` array of `meshtastic_NodeInfoLite` - full identity as + flattened fields (names, role, public key, bitfield flags such as `HAS_XEDDSA_SIGNED`; + position/telemetry live in satellite stores reached via copy-out accessors, not nested + members). Everything else in this document is a cache or a fallback for it. +- **Capacity:** `MAX_NUM_NODES`, per platform - 250 on native, 120 on nRF52840/generic + ESP32, 10 on STM32WL (see `mesh-pb-constants.h`). +- **Eviction:** oldest non-protected node when full (`getOrCreateMeshNode`). On eviction + the node's essentials are **absorbed into the warm tier** (see ยง2); on re-admission the + warm record is rehydrated back (`take()`), including the signer bit. +- **Persistence:** the node database file, saved on the usual NodeDB cadence. +- **Authority:** key pinning (`updateUser`'s "Public Key mismatch" drop), signer + provenance, and identity content all originate here. The lookup helpers that other + stores mirror: + - `copyPublicKeyAuthoritative(n, out)` - hot store, then warm tier. The pin reference + for caches; never consults opportunistic caches. + - `copyPublicKey(n, out)` - the above, then **TMM's NodeInfo cache as last resort** + (extends the encrypt-to pool for nodes both tiers have forgotten). + - `isVerifiedSignerForKey(n, key32)` - key-matched signer verdict across hot + warm. + - `isKnownXeddsaSigner(n)` - key-agnostic "should this node's signable traffic arrive + signed", across hot + warm. Gates that check only the hot store would let a + warm-evicted signer be impersonated with unsigned frames. + - `getNodeRole(n)` - hot store, then the role cached in the warm tier, else `CLIENT`. + +## 2. Warm tier - `WarmNodeStore` (NodeDB-owned) + +- **What:** the "long-tail" second tier. When a node ages out of the hot store, a minimal + record survives so DMs keep encrypting: the key is expensive to re-learn; everything + else rebuilds from traffic in seconds. +- **Entry:** exactly 40 bytes - `num(4) | last_heard(4) | public_key(32)`. The low 7 bits + of `last_heard` are omitted, and replaced with metadata (role: 4 bits, protected + category: 2, signer bit: 1), leaving ~128 s recency resolution - plenty for LRU ranking. +- **Capacity:** `WARM_NODE_COUNT` (100 on constrained parts; platform-tiered). +- **Eviction:** LRU by `last_heard`, with keyed entries outranking keyless; keyless + candidates never displace keyed entries. +- **Persistence:** nRF52840 uses a 12 KB raw-flash record-ring below LittleFS + (append/replay/compact); everywhere else `/prefs/warm.dat`. +- **Membership invariant:** a node lives in the hot **XOR** warm tier. `take()` removes + the warm record when the node is re-admitted hot, restoring role/protected/signer bits. + +## 3. TMM unified cache (base, traffic state) + +- **What:** TMM's own flat array of packed 10-byte `UnifiedCacheEntry` records - the + per-node state behind position dedup, rate limiting, unknown-packet filtering, plus two + piggybacked caches: + - `next_hop` - last-byte relay hint, written only from ACK-confirmed NextHopRouter + decisions (no TTL; keeps the slot alive across sweeps). + - a **4-bit device role** (split across the top bits of two count bytes) - the _third_ + fallback for role-aware policy after the hot store and warm tier, surviving even total + NodeDB eviction. Read through `resolveSenderRole()`, refreshed by + `updateCachedRoleFromNodeInfo()` on observed NodeInfo. +- **Entry layout:** + `node(4) | pos_fingerprint(1) | rate_count(1) | unknown_count(1) | pos_time(1) | rate_unknown_time(1) | next_hop(1)` + = 10 bytes, all platforms. Timestamps are free-running modular ticks (uint8 / nibbles) + with presence carried by non-zero sentinels - no epochs, no absolute time. +- **Capacity:** `TRAFFIC_MANAGEMENT_CACHE_SIZE`, per memory class: 2048 (PSRAM S3 / + native), 500 (medium), 400 (small), 250 (nRF52840 - deliberately class-deviant for heap + headroom), 0 when `HAS_TRAFFIC_MANAGEMENT=0`. Variant-overridable. +- **Eviction:** linear scan; insertion on a full cache evicts the stalest entry, + preferring to keep entries with a `next_hop` hint **or** a cached special (non-`CLIENT`) + role - the long-tail state this cache exists to retain (`findOrCreateEntry`'s `preferred` + test covers both, not just `next_hop`). +- **Persistence:** none - RAM/PSRAM only, rebuilt from traffic. + +## 4. TMM NodeInfo payload cache (extended, the ephemeral third tier) + +- **What:** a flat array of `NodeInfoPayloadEntry` (PSRAM-backed on hardware; see + Availability) - the full cached `User` payload (names, role, key) plus the metadata that + backs TMM's **spoofed direct NodeInfo replies** on a target's behalf, independent of + NodeDB (the serve/throttle behaviour is documented in + [traffic_management_module.md](traffic_management_module.md)). Also the last-resort key + source for `NodeDB::copyPublicKey()`. +- **Availability:** `TMM_HAS_NODEINFO_CACHE` - ESP32 with PSRAM (production home; 2000 + entries is too large for MCU internal RAM), plus native unit-test builds on the plain + heap so the trust/retention paths run in CI. +- **Entry:** `node`, `user` (full nanopb `User`), the `obsTick` recency stamp (3 min/tick), + `sourceChannel`, `decodedBitfield`, and packed 1-bit flags: `hasDecodedBitfield`, + `keySignerProven`, `hasObserved`, `hasFullUser`, `isMember`. (The direct-response throttle + no longer keeps per-entry state here - it is a pair of separate RAM tables; see the module + doc.) +- **Capacity:** `kNodeInfoCacheEntries = 2000`, linear scan (NodeInfo traffic is + low-rate). +- **Persistence:** none - this tier is deliberately ephemeral; it reconstructs from NodeDB + seeding plus observed traffic after every boot. + +### Trust & provenance model + +- **Key pin, three layers deep:** an incoming NodeInfo key is checked against + `copyPublicKeyAuthoritative()` (hot then warm - the same coverage as `updateUser`'s own + pin), and, failing NodeDB knowledge, against the cache's **own previously cached key** + (TOFU pin). Mismatches are dropped, never overwritten. A frame advertising _our own_ key + is dropped outright (impersonation). +- **`keySignerProven`:** set when a frame's XEdDSA signature was router-verified + (`mp.xeddsa_signed`) or when NodeDB already knew the node as a signer **for the same + key** (`isVerifiedSignerForKey`). Monotonic per slot; a changed key resets it. +- **Unsigned-identity gate:** a NodeInfo arriving _unsigned_ from a node we have ever + verified as a signer - per `NodeDB::isKnownXeddsaSigner()`, which covers hot **and + warm** tiers - drives no cache, role, or `updateUser()` write. (Warm coverage matters: a + signer evicted to the warm tier would otherwise be forgeable with its own public key + until re-heard. The same rule guards `Router::checkXeddsaReceivePolicy`'s + unsigned-broadcast drop.) +- **Serve gate honesty:** only a genuinely _heard_ NODEINFO frame stamps + `obsTick`/`hasObserved`. Seeding and write-through are knowledge, not observation - they + can never make a silent node look alive to the replay path. The 6 h serve window is + enforced by the sweep-cleared `hasObserved` bit; the spoofed-reply throttle that gate + feeds lives in the module (see [traffic_management_module.md](traffic_management_module.md)). + +### Consistency with NodeDB (anti-entropy) + +Four mechanisms keep this tier a superset of NodeDB's identities. All **merge rather than +overwrite**, so a keyless commit never costs the cache a learned TOFU key. + +| Mechanism | When | Role | +| --------------------------------------------------------------------- | --------------------------- | -------------------------------- | +| Write-through hooks (`onNodeIdentityCommitted`, `onNodeKeyCommitted`) | every identity/key commit | immediate upsert | +| Reconcile sweep (`reconcileNodeInfoFromNodeDBLocked`) | boot seed, then hourly | re-seed from hot + warm tiers | +| Membership refresh | inside the hourly reconcile | re-mark which nodes NodeDB holds | +| Purge hooks (`purgeNode`, `purgeAll`) | node removal / reset | drop the node from both caches | + +Two details that bite: the reconcile sweep transfers signer verdicts only when **key-matched**; +and membership refresh clears-then-re-marks from both tiers rather than a per-entry NodeDB lookup +each sweep (which would be O(entries x members) under the lock). A keyless warm-tier record still +marks membership (`isMember`) even though it has no `User` to seed - `isMember` is a keep-alive, +independent of `hasFullUser`. Because the re-mark is only hourly, hook-driven additions and +`purgeNode()` removals are immediate, but a **passive** NodeDB eviction may lag membership by up to +an hour. + +**Retention:** no timed eviction. Slots die only by LRU displacement on insert, ranked by +trust tiers - members and signer-proven keys are stickiest; the seeding pass additionally +refuses to churn one member out for another (`spareMembers`). + +**Key-commit funnel:** every path that writes a remote key into the hot store must route +the write-through. Full-identity commits funnel through `NodeDB::updateUser()`; bare-key +commits (admin-channel learn in `Router::perhapsDecode`, manual verification in +`KeyVerificationModule`) funnel through `NodeDB::commitRemoteKey()`, which carries an +explicit `KeyCommitTrust` provenance (`ManuallyVerified` maps to `proven=true` in this +cache). Never assign `info->public_key` directly when **learning or rotating a remote +key** - the cache would silently diverge until the next reconcile. (The lone direct write +in `getOrCreateMeshNode()`'s warm-tier re-admission is exempt: it restores a key the warm +tier already holds, which this cache already tracks as a member, so nothing new is learned +and the hourly reconcile re-seeds it even if the packet path had LRU-evicted that slot.) + +**Enable gate:** the write-through hooks, the sweep, the packet path, **and the +`copyPublicKey()`/`copyUser()` accessors** all no-op while `moduleConfig.has_traffic_management` +is off, so cache content, maintenance, and reads are keyed to the same condition. This enforces +(not just documents) the corollary that the pubkey-pool superset property holds only while the +module is enabled: a disabled module's frozen cache never feeds PKI resolution or name +rehydration. + +### Tick clocks and wrap safety + +All TMM timestamps are free-running modular ticks (uint8 or nibble) from `clockMs()`; modular +subtraction is correct only while the true age stays below the counter period, so every clock +needs something to clear expired state before it aliases. + +| Clock | Tick / period | Window | Kept honest by | +| ------------------ | -------------- | --------------- | -------------------------------------------------- | +| pos | 6 min / 25.6 h | <=255 ticks | 60 s sweep (margin as low as 1 tick at the clamp) | +| rate | 5 min / 80 min | <=15 ticks | sweep + read-time window reset (`isRateLimited()`) | +| unknown | 1 min / 16 min | 12 ticks | sweep + read-time window reset | +| NodeInfo `obsTick` | 3 min / 12.8 h | 120 ticks (6 h) | sweep only | + +`obsTick` is the sharp case: `maintainNodeInfoCacheLocked()` clearing `hasObserved` is the +_sole_ guarantee the 6 h serve gate never reads an aliased stamp. That makes the sweep a +compile-time invariant - guarded by `TMM_HAS_NODEINFO_CACHE` **alone** (never +`TRAFFIC_MANAGEMENT_CACHE_SIZE`, which a variant may zero independently), mirroring `purgeAll()`: +a build that has the cache always has its sweep. + +The warm tier is different by design: `WarmNodeStore.last_heard` is an **absolute** unix-seconds +timestamp (128 s quantised), so it cannot wrap until 2106 and needs no sweep - the TMM caches +chose 1-byte ticks instead to stay at 10 B/entry across up to 2048 entries. + +### Direct-response behavior + +How this cache's identities are served as spoofed direct NodeInfo replies - the serve gates, +the per-requester/per-target/global throttle, and the "throttled forwards, not dropped" +behaviour - is documented with the module in +[traffic_management_module.md](traffic_management_module.md). + +--- + +## Property matrix + +Side-by-side view of what each store actually holds ("-" = not held). Details and +rationale live in the per-store sections above. + +| Property | 1. Hot store (`NodeInfoLite`) | 2. Warm tier (`WarmNodeEntry`) | 3. NodeInfo cache (`NodeInfoPayloadEntry`) | 4. Unified cache (`UnifiedCacheEntry`) | +| ------------------------- | -------------------------------- | ---------------------------------------------- | ------------------------------------------------------------- | ---------------------------------------------------- | +| Node number | yes | yes | yes (0 = free slot) | yes (0 = free slot) | +| Names + user id | yes (flattened fields) | - | yes (full `User`, when `hasFullUser`) | - | +| Public key (32 B) | yes (authoritative) | yes (keyed entries) | yes (TOFU or proven; pinned against tiers 1-2) | - | +| Signer provenance | `HAS_XEDDSA_SIGNED` bitfield bit | 1 signer bit (shared with `last_heard`) | `keySignerProven` (monotonic per key) | - | +| Device role | `role` field | 4-bit role (metadata steal) | inside the cached `User` | 4-bit role in count-byte top bits (final fallback) | +| Recency | `last_heard` (unix secs) | `last_heard` (unix secs, 128 s quantised) | `obsTick` (3 min modular tick) + `hasObserved` | pos/rate/unknown modular ticks | +| Position / telemetry | via satellite copy-out accessors | - | - | 8-bit position _fingerprint_ only (dedup) | +| Protected / favorite | bitfield flags | 2-bit protected category | - (`isMember` keep-alive instead) | - | +| Routing hint (`next_hop`) | yes (persisted field) | - | - | ACK-confirmed relay byte (preloaded from tier 1) | +| Direct-reply metadata | - | - | `sourceChannel`, `decodedBitfield` (+ `hasDecodedBitfield`) | - | +| Traffic-shaping counters | - | - | - | rate + unknown counts, pos fingerprint | +| Entry size | largest (full struct) | 40 B exact | ~`sizeof(User)`+8, platform-padded (no size assert by design) | 10 B exact | +| Capacity | `MAX_NUM_NODES` (250/120/10) | `WARM_NODE_COUNT` (~100) | `kNodeInfoCacheEntries` (2000) | `TRAFFIC_MANAGEMENT_CACHE_SIZE` (2048/500/400/250/0) | +| Persistence | node DB file | raw-flash ring (nRF52840) or `/prefs/warm.dat` | none (rebuilt from seed + traffic) | none | +| Storage | RAM | RAM + flash | PSRAM on hardware; plain heap in native tests | PSRAM when available, else heap | + +## How a lookup falls through the tiers + +```text +identity/role/key consumer + โ”‚ + โ–ผ + 1. hot store (NodeInfoLite) full identity, authoritative + โ”‚ miss + โ–ผ + 2. warm tier (WarmNodeStore) key + role/protected/signer bits, persisted + โ”‚ miss + โ–ผ + 3. TMM NodeInfo cache (extended) full User payloads + TOFU/proven keys, ephemeral + โ”‚ miss (role-only: 4-bit role in the unified cache) + โ–ผ + defaults (no key; role = CLIENT) +``` + +The unified cache (ยง3) sits beside this chain rather than in it: it is traffic-shaping +state keyed by the same NodeNum, whose role bits act as the final role fallback when all +three identity tiers miss. diff --git a/docs/traffic_management_module.md b/docs/traffic_management_module.md new file mode 100644 index 000000000..bb533edbe --- /dev/null +++ b/docs/traffic_management_module.md @@ -0,0 +1,193 @@ +# The Traffic Management Module (TMM) + +TMM is an optional module that shapes **transit** traffic on busy meshes. Large networks get +noisy fast - repeated position packets, bursty senders, and unknown/undecryptable frames all +burn limited airtime and power - and TMM filters or answers that traffic before it is +rebroadcast. On supported targets it **ships enabled** (`has_traffic_management` defaults to +true) with position dedup running at its 11 h default; the other features each default off, so +the module is on out of the box but opt-in per feature. It was introduced in +[meshtastic/firmware#9358](https://github.com/meshtastic/firmware/pull/9358). + +This document covers the module's behaviour, with a deep dive on the two TMM-specific +NodeInfo features - **direct-serve** (answering NodeInfo requests on another node's behalf) +and the **throttling** that bounds it. The identity/traffic-state stores those features read +from are documented separately in [node_info_stores.md](node_info_stores.md); this file owns +the direct-serve and throttle behaviour, that file owns the stores. + +Sources of truth: `src/modules/TrafficManagementModule.{h,cpp}`, defaults in +`src/mesh/Default.h`. + +--- + +## How it runs + +- **Enablement is three-gated.** Compile-time `HAS_TRAFFIC_MANAGEMENT` (with the + `MESHTASTIC_EXCLUDE_TRAFFIC_MANAGEMENT` build exclusion), then the runtime + `moduleConfig.has_traffic_management` presence flag. While the runtime gate is off, the + packet path, the maintenance sweep, the NodeDB write-through hooks, and the cache accessors + all no-op - content, maintenance, and reads are keyed to the same condition. +- **It runs before `RoutingModule`** in `callModules()`. Returning `STOP` from + `handleReceived()` fully consumes a packet, so it is never rebroadcast; `CONTINUE` lets it + proceed through normal relay handling. +- **State is cheap.** Per-node traffic-shaping counters live in a flat 10-byte + `UnifiedCacheEntry` array (position fingerprint, rate/unknown counters, modular tick + stamps, a next-hop hint, and a 4-bit role fallback) - see + [node_info_stores.md ยง3](node_info_stores.md). Direct-serve additionally reads the PSRAM + NodeInfo payload cache (or the NodeDB fallback when that cache is absent). + +## What it does + +| Feature | Default | In one line | +| ------------------------ | -------------- | -------------------------------------------------------------- | +| Position dedup | on, 11 h | Suppresses a stationary sender's repeated position broadcasts. | +| Per-sender rate limit | off | Caps how many transit packets one sender may spend per window. | +| Unknown-packet filter | off | Drops a sender's undecryptable traffic past a threshold. | +| NodeInfo direct response | off | Answers a NodeInfo request on the target's behalf (see below). | +| Position precision clamp | channel-driven | Truncates relayed position to the channel's precision. | + +Config lives under `moduleConfig.traffic_management`; the per-feature sections below give the +exact fields, defaults, and behaviour. NodeInfo direct response has its own deep-dive sections +after these. + +### Position dedup + +`position_min_interval_secs` (default 11 h; `0` disables). Drops a duplicate position from the +same sender inside the interval, where "duplicate" means the same fingerprint on the channel's +`position_precision` grid (firmware default 19-bit, ~90 m cells). Role caps only ever _shorten_ +the interval: **tracker / TAK tracker โ†’ 1 h**, **lost-and-found โ†’ 15 min**. + +### Per-sender rate limit + +`rate_limit_window_secs` + `rate_limit_max_packets` (default off; either `0` disables). Drops a +sender's transit packets once it exceeds the budget within the window. + +### Unknown-packet filter + +`unknown_packet_threshold` (default `0` = off). Drops undecryptable traffic from a sender once it +passes the threshold within a ~5 min window. + +### NodeInfo direct response + +`nodeinfo_direct_response_max_hops` (default `0` = off). When set, a neighbour that already +holds the target's identity answers a unicast NodeInfo request on its behalf, saving the full +round trip. This is TMM's most security-sensitive feature; the serve gates and the throttle +that bounds it are covered in the two dedicated sections below. + +### Position precision clamp + +Driven by the channel's `position_precision` ceiling (else the 19-bit firmware default). +`alterReceived()` truncates relayed position coordinates to that precision. + +### Shelved + +Present in the config surface but currently no-ops in the module, deferred until the right +heuristics are settled: hop exhaustion for position/telemetry (`exhaust_hop_position` / +`exhaust_hop_telemetry`) and `router_preserve_hops`. `alterReceived()` leaves rebroadcast hop +handling untouched. + +--- + +## NodeInfo direct response (direct-serve) + +Normally a unicast NodeInfo request travels all the way to the target and the reply travels +all the way back. On a large mesh that is several hops of airtime per lookup. When +`nodeinfo_direct_response_max_hops > 0`, a neighbour that already holds the target's identity +answers **on the target's behalf** with a spoofed reply, cutting the round trip to one hop. + +**Data source.** The reply payload comes from the TMM NodeInfo payload cache (PSRAM-backed; +full cached `User` plus provenance metadata) or, on builds without that cache, from the +NodeDB fallback. Both are described in [node_info_stores.md ยง4](node_info_stores.md); this +feature is a _consumer_ of them. + +**Decision pipeline** (`shouldRespondToNodeInfo()`), in order - any failure returns `false` +and the request is left to propagate normally: + +1. **Eligibility** (checked by the caller): `nodeinfo_direct_response_max_hops > 0`, + `NODEINFO_APP` portnum, `want_response`, and the packet is unicast, not to us, not from us. +2. **Hop clamp** (`isMinHopsFromRequestor()`): respond only when the requester is within the + role-clamped hop ceiling - **routers up to 3 hops** (`kRouterDefaultMaxHops`, may be + lowered by config), **clients direct-only, 0 hops** (`kClientDefaultMaxHops`). +3. **Identity lookup**: NodeInfo cache hit (cache path) or NodeDB fallback (fallback path). +4. **Staleness gate (6 h)**: never vouch for a node not genuinely _heard_ within the serve + window. Only a real observed frame stamps the recency bit - seeding and write-through are + knowledge, not observation, so a silent node can never look alive to this path. +5. **Signer-provenance gate** (`TMM_NODEINFO_REPLAY_SIGNED_GATE`, default on): vouch only for + an identity whose key is signer-proven (XEdDSA-verified, directly or inherited from + NodeDB). A trust-on-first-use identity is left for the genuine node - or another + cache-holder that _has_ proof - to answer. Bypassed when PKI is compiled out. +6. **Throttle** (`directResponseAllowed()`): see the next section. + +**The spoofed reply.** On success TMM emits a NodeInfo reply with `from` set to the _target_ +(so the requester sees a valid answer), `to` the requester, `hop_limit = 0` (one hop only), +`request_id` the original packet id, and the OK_TO_MQTT bit set from local +`config.lora.config_ok_to_mqtt` policy. The requester's own identity claim in the request is +**not** written back to NodeDB - a unicast NodeInfo is unsigned, so treating it as an +identity update would be unauthenticated. `nodeinfo_cache_hits` counts only replies actually +sent. + +--- + +## Throttling direct responses + +A direct reply is addressed to the requesting packet's `from` and spoofs the requested +target - and **both fields are unauthenticated header data**. Without a bound, an attacker +crafts requests carrying a victim's address as `from`, and every neighbour holding the target +transmits at the victim: a reflector-amplification primitive. The throttle is the security +core of this feature, checked immediately before a reply would go out so requests declined for +other reasons never consume the budget. + +**Three bounds**, all keyed off `clockMs()` and evaluated under `cacheLock`: + +| Bound | Window | Bounds | +| ------------------------------------------------ | ------ | ------------------------------------------------ | +| Per requester (`kDirectResponsePerRequesterMs`) | 60 s | how much any single node can be made to receive | +| Per target (`kDirectResponsePerTargetMs`) | 60 s | how often we vouch for the same identity | +| Global airtime floor (`kDirectResponseGlobalMs`) | 1 s | total spoofed TX, regardless of key distribution | + +**Mechanism.** The two per-key bounds are fixed **8-slot LRU tables in internal RAM** +(`directRequesterSeen`, `directTargetSeen`) - _not_ the PSRAM NodeInfo cache - so they behave +identically with and without PSRAM, on the cache path and the NodeDB-fallback path alike. +Timestamps are full `uint32` milliseconds compared by wrap-safe subtraction, so there is no +tick clock and no maintenance sweep to keep them honest. `directResponseAllowed(requester, +target, now)` resolves a slot in _both_ tables before stamping either - so a reply one axis +throttles never consumes the other axis's budget - then records the send on all three bounds. +The global floor is a single stamp, checked first as the cheap common case. + +**When a table fills.** For an unseen key with no free slot, `directResponseSlot()` evicts the +**least-recently-used** entry (smallest last-reply time) and admits the new key. The LRU +victim is by construction the entry closest to expiring anyway, so eviction is the +lowest-cost choice. An attacker who cycles more than 8 distinct requesters or targets - easy, +since both are unauthenticated - evicts entries and defeats _per-key_ throttling for the +cycled keys; that is expected, and why the **global 1 s floor is the hard backstop**. It is a +single stamp, cannot fill, and caps total spoofed replies at ~1/s no matter what. Per-key +throttling degrades gracefully to the floor under pressure. + +**Throttled is not dropped.** A throttled request returns `false`, which lets +`handleReceived()` `CONTINUE`: the request forwards toward the genuine target (which can +answer itself) rather than being black-holed. A requester whose first reply was lost on a +noisy link would otherwise get silence for the whole window; repeats of the same packet id +are already absorbed by the router's duplicate detection. + +**Evolution.** The original design split throttling by path: a per-entry `respTick` stamp in +each NodeInfo cache slot (cache path, 30 s, swept for wrap-safety) plus a single module-global +stamp for the NodeDB fallback (30 s, neither per-requester nor per-target). Those two routes +were unified into the symmetric per-requester + per-target RAM tables above, aligned to a +single 60 s window, so both axes hold with and without PSRAM and the cache entry no longer +carries throttle state. + +--- + +## Configuration + +All tunables live under `moduleConfig.traffic_management`; the whole module is gated by the +`has_traffic_management` presence flag, and each per-feature section above lists its own +field(s) and default. Two related sets of knobs are **firmware constants, not config**: the +role-based position caps `default_traffic_mgmt_tracker_position_min_interval_secs` (1 h) and +`default_traffic_mgmt_lost_and_found_position_min_interval_secs` (15 min), and the direct-serve +throttle windows (the `kDirectResponse*Ms` constants). + +## See also + +- [node_info_stores.md](node_info_stores.md) - the NodeDB hot store, warm tier, TMM NodeInfo + payload cache, and unified cache that the direct-serve path reads from, plus their trust, + provenance, and anti-entropy model. diff --git a/extra_scripts/esp32_pre.py b/extra_scripts/esp32_pre.py index 8e21770e9..b2c4171e7 100755 --- a/extra_scripts/esp32_pre.py +++ b/extra_scripts/esp32_pre.py @@ -71,3 +71,99 @@ def mtjson_esp32_part(target, source, env): env.AddPreAction("mtjson", mtjson_esp32_part) + + +# --------------------------------------------------------------------------- +# HybridCompile cache-poisoning workaround (pioarduino platform-espressif32) +# +# The platform decides whether the precompiled Arduino IDF libs can be reused +# by hashing only the custom_sdkconfig text + mcu +# (builder/frameworks/arduino.py::matching_custom_sdkconfig). The board JSON's +# PSRAM configuration (BOARD_HAS_PSRAM / memory_type) is not part of that +# hash, and nearly all our S3 variants share esp32s3_base's identical +# custom_sdkconfig. Building a PSRAM env (e.g. heltec-v4) followed by a +# no-PSRAM env (e.g. heltec-v3) therefore skips the framework-libs recompile +# and links CONFIG_SPIRAM=y libs into the no-PSRAM firmware, which boot-loops +# with "quad_psram: PSRAM chip is not connected". +# +# Workaround: before the platform's arduino.py SConscript reads the option, +# append a comment line derived from the board's PSRAM/memory configuration to +# this env's custom_sdkconfig. The comment is inert in kconfig but changes the +# md5, forcing the IDF-libs recompile whenever the PSRAM class changes. The +# derivation mirrors espidf.py::generate_board_specific_config(). +# +# The marker must stay lowercase: arduino.py greps custom_sdkconfig for the +# substrings "PSRAM" and "CONFIG_SPIRAM=y" (has_psram_config) and would +# otherwise treat every board as having PSRAM. +# --------------------------------------------------------------------------- + +SDKCONFIG_CACHE_KEY = "meshtastic_hybridcompile_cache_key" + + +def spiram_cache_class(env): + board = env.BoardConfig() + mcu = board.get("build.mcu", "esp32") + + # memory_type: platformio.ini override > build.arduino.memory_type > build.memory_type + memory_type = None + try: + memory_type = env.GetProjectOption("board_build.memory_type", None) + except Exception: + pass + if not memory_type: + build_section = board.get("build", {}) + memory_type = build_section.get("arduino", {}).get( + "memory_type" + ) or build_section.get("memory_type") + + extra_flags = board.get("build.extra_flags", "") + if isinstance(extra_flags, str): + has_psram = "PSRAM" in extra_flags + else: + has_psram = any("PSRAM" in str(flag) for flag in extra_flags) + if not has_psram: + if memory_type and ( + "opi" in memory_type.lower() or "psram" in memory_type.lower() + ): + has_psram = True + elif "psram_type" in board.get("build", {}): + has_psram = True + + if has_psram: + psram_type = None + try: + psram_type = env.GetProjectOption("board_build.psram_type", None) + except Exception: + pass + if not psram_type and memory_type and len(memory_type.split("_")) == 2: + psram_type = memory_type.split("_")[1] + if not psram_type: + psram_type = board.get("build.psram_type", "") or ( + "hex" if mcu == "esp32p4" else "qio" + ) + psram_type = psram_type.lower() + if psram_type == "opi" and mcu == "esp32s3": + spiram = "oct" + elif psram_type == "hex": + spiram = "hex" + else: + spiram = "quad" + else: + spiram = "none" + + return "memory_type=%s spiram=%s" % ((memory_type or "default").lower(), spiram) + + +def tag_sdkconfig_cache_key(env): + config = env.GetProjectConfig() + section = "env:" + env["PIOENV"] + if not config.has_option(section, "custom_sdkconfig"): + return + current = env.GetProjectOption("custom_sdkconfig") + if SDKCONFIG_CACHE_KEY in current: + return + marker = "# %s: %s" % (SDKCONFIG_CACHE_KEY, spiram_cache_class(env)) + config.set(section, "custom_sdkconfig", current.rstrip("\n") + "\n" + marker) + + +tag_sdkconfig_cache_key(env) diff --git a/extra_scripts/windows_link_flags.py b/extra_scripts/windows_link_flags.py new file mode 100644 index 000000000..b86322a0d --- /dev/null +++ b/extra_scripts/windows_link_flags.py @@ -0,0 +1,16 @@ +#!/usr/bin/env python3 +# trunk-ignore-all(ruff/F821) +# trunk-ignore-all(flake8/F821): For SConstruct imports +# +# PlatformIO routes build_flags to the compile step only, so the static link +# flags are appended here, as wasm_link_flags.py does for [env:native-wasm]. +Import("env") + +if env["PIOENV"].startswith("native-windows"): + env.Append( + LINKFLAGS=[ + "-static", + "-static-libgcc", + "-static-libstdc++", + ] + ) diff --git a/monitor/filter_c3_exception_decoder.py b/monitor/filter_c3_exception_decoder.py index fbc372bcf..b6d55f354 100644 --- a/monitor/filter_c3_exception_decoder.py +++ b/monitor/filter_c3_exception_decoder.py @@ -1,6 +1,3 @@ -# trunk-ignore-all(bandit/B404): subprocess is used to call addr2line -# trunk-ignore-all(bandit/B603): subprocess is used to call addr2line - # Copyright (c) 2014-present PlatformIO # # Licensed under the Apache License, Version 2.0 (the "License"); diff --git a/platformio.ini b/platformio.ini index 225a8f938..2fea56967 100644 --- a/platformio.ini +++ b/platformio.ini @@ -54,6 +54,7 @@ build_flags = -Wno-missing-field-initializers -DRADIOLIB_EXCLUDE_LORAWAN=1 -DMESHTASTIC_EXCLUDE_DROPZONE=1 -DMESHTASTIC_EXCLUDE_REPLYBOT=1 + -DMESHTASTIC_EXCLUDE_RANGETEST=1 -DMESHTASTIC_EXCLUDE_REMOTEHARDWARE=1 -DMESHTASTIC_EXCLUDE_HEALTH_TELEMETRY=1 -DMESHTASTIC_EXCLUDE_POWERSTRESS=1 ; exclude power stress test module from main firmware @@ -122,12 +123,12 @@ lib_deps = [radiolib_base] lib_deps = # renovate: datasource=github-tags depName=RadioLib packageName=jgromes/RadioLib - https://github.com/jgromes/RadioLib/archive/10e7925db2727e1f5c1c796191905d0a7d955eec.zip + https://github.com/jgromes/RadioLib/archive/6d8934836678d8894e3d556550475b37dce3e2b6.zip [device-ui_base] lib_deps = # renovate: datasource=git-refs depName=meshtastic/device-ui packageName=https://github.com/meshtastic/device-ui gitBranch=master - https://github.com/meshtastic/device-ui/archive/33917d583eb3f4d6f93a24122a88101221afcc2d.zip + https://github.com/meshtastic/device-ui/archive/ef573c368767625ffbe8c32cf921ea7366f2dd53.zip ; Common libs for environmental measurements in telemetry module [environmental_base] @@ -170,7 +171,7 @@ lib_deps = https://github.com/adafruit/Adafruit_TSL2591_Library/archive/refs/tags/1.4.5.zip # renovate: datasource=github-tags depName=EmotiBit MLX90632 packageName=emotibit/EmotiBit_MLX90632 https://github.com/EmotiBit/EmotiBit_MLX90632/archive/refs/tags/v1.0.8.zip - # renovate: datasource=github-tags depName=Adafruit MLX90614 packageName=adafruit/Adafruit_MLX90614 + # renovate: datasource=github-tags depName=Adafruit MLX90614 packageName=adafruit/Adafruit-MLX90614-Library https://github.com/adafruit/Adafruit-MLX90614-Library/archive/refs/tags/2.1.6.zip # renovate: datasource=github-tags depName=INA3221_RT packageName=RobTillaart/INA3221_RT https://github.com/RobTillaart/INA3221_RT/archive/refs/tags/0.4.2.zip @@ -200,7 +201,7 @@ lib_deps = https://github.com/adafruit/Adafruit_TSL2561/archive/refs/tags/1.1.3.zip # renovate: datasource=github-tags depName=BH1750_WE packageName=wollewald/BH1750_WE https://github.com/wollewald/BH1750_WE/archive/refs/tags/1.1.10.zip - # renovate: datasource=git-refs depName=Fusion packageName=https://github.com/meshtastic/Fusion gitBranch=master + # renovate: datasource=git-refs depName=Fusion packageName=https://github.com/meshtastic/Fusion gitBranch=main https://github.com/meshtastic/Fusion/archive/936e1eb1e5ea19e8f4ed467526f91adeebb4f53c.zip # renovate: datasource=github-tags depName=Sensirion Core packageName=sensirion/arduino-core https://github.com/Sensirion/arduino-core/archive/refs/tags/0.7.3.zip diff --git a/protobufs b/protobufs index f5b94bc36..bfd718fa1 160000 --- a/protobufs +++ b/protobufs @@ -1 +1 @@ -Subproject commit f5b94bc36786e3862eb16f4a68169709ee23c809 +Subproject commit bfd718fa1dcb019ed11b7b7185f37318abebdafc diff --git a/scripts/add_mbedtls_sources.py b/scripts/add_mbedtls_sources.py index 2b6a0149a..98027c2e6 100644 --- a/scripts/add_mbedtls_sources.py +++ b/scripts/add_mbedtls_sources.py @@ -10,13 +10,11 @@ # trunk-ignore-all(ruff/F821) # trunk-ignore-all(flake8/F821): Import/env/Return are SCons-injected globals -# trunk-ignore-all(ruff/E402) -# trunk-ignore-all(flake8/E402): stdlib imports must follow Import("env") -Import("env") - import glob import os +Import("env") + framework_dir = env.PioPlatform().get_package_dir("framework-arduinopico") if not framework_dir: print("[add_mbedtls_sources] framework-arduinopico package not found - skipping") diff --git a/src/FSCommon.cpp b/src/FSCommon.cpp index 7b0595aa9..38b704e73 100644 --- a/src/FSCommon.cpp +++ b/src/FSCommon.cpp @@ -12,6 +12,85 @@ #include "SPILock.h" #include "configuration.h" +#if defined(ARCH_PORTDUINO) +#include +#endif + +#if defined(ARCH_NRF52) || defined(ARCH_STM32) +// Adafruit_LittleFS (nRF52) and STM32_LittleFS both wrap littlefs v1, which predates lfs_fs_size(). +// Count the blocks currently in use with lfs_traverse() instead. +static int fsCountBlockCb(void *ctx, lfs_block_t) +{ + *static_cast(ctx) += 1; + return 0; +} + +size_t fsTotalBytes() +{ + lfs_t *fs = FSCom._getFS(); + if (!fs || !fs->cfg) + return 0; + return (size_t)fs->cfg->block_count * (size_t)fs->cfg->block_size; +} + +size_t fsUsedBytes() +{ + lfs_t *fs = FSCom._getFS(); + if (!fs || !fs->cfg) + return 0; + size_t blocks = 0; + FSCom._lockFS(); + int err = lfs_traverse(fs, fsCountBlockCb, &blocks); + FSCom._unlockFS(); + if (err < 0) + return fsTotalBytes(); // report "full" so capacity checks fail safe + return blocks * (size_t)fs->cfg->block_size; +} +#elif defined(ARCH_RP2040) +// arduino-pico reports capacity through FSInfo rather than as methods. +size_t fsTotalBytes() +{ + FSInfo info; + return FSCom.info(info) ? (size_t)info.totalBytes : 0; +} + +size_t fsUsedBytes() +{ + FSInfo info; + return FSCom.info(info) ? (size_t)info.usedBytes : 0; +} +#elif defined(ARCH_PORTDUINO) +// Portduino is backed by the host filesystem; ask the OS about the volume holding the working +// directory. std::filesystem keeps this working on native-windows too, where statvfs() does not +// exist. On error we report "full" so capacity checks fail safe. +size_t fsTotalBytes() +{ + std::error_code ec; + const auto info = std::filesystem::space(".", ec); + return ec ? 0 : (size_t)info.capacity; +} + +size_t fsUsedBytes() +{ + std::error_code ec; + const auto info = std::filesystem::space(".", ec); + if (ec || info.capacity < info.available) + return fsTotalBytes(); + return (size_t)(info.capacity - info.available); +} +#else +// ESP32 LittleFS and the nRF54L15 wrapper expose these directly. +size_t fsTotalBytes() +{ + return FSCom.totalBytes(); +} + +size_t fsUsedBytes() +{ + return FSCom.usedBytes(); +} +#endif + // Software SPI is used by MUI so disable SD card here until it's also implemented #if defined(HAS_SDCARD) && !defined(SDCARD_USE_SOFT_SPI) #include @@ -30,44 +109,6 @@ SPIClass SPI_HSPI(HSPI); #endif // HAS_SDCARD -/** - * @brief Copies a file from one location to another. - * - * @param from The path of the source file. - * @param to The path of the destination file. - * @return true if the file was successfully copied, false otherwise. - */ -bool copyFile(const char *from, const char *to) -{ -#ifdef FSCom - // take SPI Lock - concurrency::LockGuard g(spiLock); - unsigned char cbuffer[16]; - - File f1 = FSCom.open(from, FILE_O_READ); - if (!f1) { - LOG_ERROR("Failed to open source file %s", from); - return false; - } - - File f2 = FSCom.open(to, FILE_O_WRITE); - if (!f2) { - LOG_ERROR("Failed to open destination file %s", to); - return false; - } - - while (f1.available() > 0) { - byte i = f1.read(cbuffer, 16); - f2.write(cbuffer, i); - } - - f2.flush(); - f2.close(); - f1.close(); - return true; -#endif -} - /** * Renames a file from pathFrom to pathTo. * diff --git a/src/FSCommon.h b/src/FSCommon.h index 080ede691..7daa57ad9 100644 --- a/src/FSCommon.h +++ b/src/FSCommon.h @@ -56,8 +56,13 @@ using namespace Adafruit_LittleFS_Namespace; using namespace Adafruit_LittleFS_Namespace; #endif +// Filesystem capacity, in bytes. Only ESP32's LittleFS and the nRF54L15 wrapper expose totalBytes()/usedBytes() +// directly; the other backends need per-platform work (littlefs v1 traversal, FSInfo, statvfs), so callers must +// use these helpers rather than reaching into FSCom. +size_t fsTotalBytes(); +size_t fsUsedBytes(); + void fsInit(); -bool copyFile(const char *from, const char *to); bool renameFile(const char *pathFrom, const char *pathTo); bool fsFormat(); std::vector getFiles(const char *dirname, uint8_t levels, size_t maxCount = 64, bool *wasLimited = nullptr); diff --git a/src/MessageStore.cpp b/src/MessageStore.cpp index 6332c0e82..8b6775eec 100644 --- a/src/MessageStore.cpp +++ b/src/MessageStore.cpp @@ -65,6 +65,15 @@ static inline const char *getTextFromPool(uint16_t offset) return &g_messagePool[offset]; } +static inline bool isIgnoredNodeNum(uint32_t nodeNum) +{ + if (nodeNum == 0 || nodeNum == NODENUM_BROADCAST) + return false; + + const meshtastic_NodeInfoLite *node = nodeDB->getMeshNode(nodeNum); + return nodeInfoLiteIsIgnored(node); +} + // Helper: assign a timestamp (RTC if available, else boot-relative) static inline void assignTimestamp(StoredMessage &sm) { @@ -163,9 +172,44 @@ static inline void autosaveTick(MessageStore *store) } #endif -// Add from incoming/outgoing packet -const StoredMessage &MessageStore::addFromPacket(const meshtastic_MeshPacket &packet) +bool MessageStore::shouldStorePacket(const meshtastic_MeshPacket &packet) const { + const uint32_t localNode = nodeDB->getNodeNum(); + const bool isDM = packet.to != 0 && packet.to != NODENUM_BROADCAST; + if (isDM) { + const bool outgoing = packet.from == 0 || packet.from == localNode; + const uint32_t peer = outgoing ? packet.to : packet.from; + return !isIgnoredNodeNum(peer); + } + + if (packet.from != 0 && packet.from != localNode) + return !isIgnoredNodeNum(packet.from); + + return true; +} + +bool MessageStore::isMessageVisible(const StoredMessage &msg) const +{ + const uint32_t localNode = nodeDB->getNodeNum(); + if (msg.type == MessageType::DM_TO_US) { + const uint32_t peer = (msg.sender == localNode) ? msg.dest : msg.sender; + return !isIgnoredNodeNum(peer); + } + + if (msg.sender != 0 && msg.sender != localNode) + return !isIgnoredNodeNum(msg.sender); + + return true; +} + +// Add from incoming/outgoing packet +const StoredMessage *MessageStore::tryAddFromPacket(const meshtastic_MeshPacket &packet) +{ + if (!shouldStorePacket(packet)) { + LOG_DEBUG("Drop store 0x%08x", packet.from); + return nullptr; + } + StoredMessage sm; assignTimestamp(sm); sm.channelIndex = packet.channel; @@ -196,34 +240,7 @@ const StoredMessage &MessageStore::addFromPacket(const meshtastic_MeshPacket &pa markMessageStoreUnsaved(); #endif - return liveMessages.back(); -} - -// Outgoing/manual message -void MessageStore::addFromString(uint32_t sender, uint8_t channelIndex, const std::string &text) -{ - StoredMessage sm; - - // Always use our local time (helper handles RTC vs boot time) - assignTimestamp(sm); - - sm.sender = sender; - sm.channelIndex = channelIndex; - sm.textOffset = storeTextInPool(text.c_str(), text.size()); - sm.textLength = text.size(); - - // Use the provided destination - sm.dest = sender; - sm.type = MessageType::DM_TO_US; - - // Outgoing messages always start with unknown ack status - sm.ackStatus = AckStatus::NONE; - - addLiveMessage(sm); - -#if ENABLE_MESSAGE_PERSISTENCE - markMessageStoreUnsaved(); -#endif + return &liveMessages.back(); } #if ENABLE_MESSAGE_PERSISTENCE @@ -323,28 +340,33 @@ void MessageStore::loadFromFlash() resetMessagePool(); // reset pool when loading #ifdef FSCom - concurrency::LockGuard guard(spiLock); + { + concurrency::LockGuard guard(spiLock); - if (!FSCom.exists(filename.c_str())) - return; + if (!FSCom.exists(filename.c_str())) + return; - auto f = FSCom.open(filename.c_str(), FILE_O_READ); - if (!f) - return; + auto f = FSCom.open(filename.c_str(), FILE_O_READ); + if (!f) + return; - uint8_t count = 0; - f.readBytes(reinterpret_cast(&count), 1); - if (count > MAX_MESSAGES_SAVED) - count = MAX_MESSAGES_SAVED; + uint8_t count = 0; + f.readBytes(reinterpret_cast(&count), 1); + if (count > MAX_MESSAGES_SAVED) + count = MAX_MESSAGES_SAVED; - for (uint8_t i = 0; i < count; ++i) { - StoredMessage m; - if (!readMessageRecord(f, m)) - break; - liveMessages.push_back(m); + for (uint8_t i = 0; i < count; ++i) { + StoredMessage m; + if (!readMessageRecord(f, m)) + break; + liveMessages.push_back(m); + } + + f.close(); } - f.close(); + if (pruneHiddenMessages()) + saveToFlash(); #endif // Loading messages does not trigger an autosave g_messageStoreHasUnsavedChanges = false; @@ -406,6 +428,13 @@ template static void eraseAllMatches(std::dequegetNodeNum(); + auto pred = [&](const StoredMessage &m) { + if (m.sender == nodeNum) + return true; + if (m.type != MessageType::DM_TO_US) + return false; + return m.sender == local ? m.dest == nodeNum : m.sender == nodeNum; + }; + eraseAllMatches(liveMessages, pred); + saveToFlash(); +} + // Delete oldest message in a direct chat with a node void MessageStore::deleteOldestMessageWithPeer(uint32_t peer) { @@ -460,7 +503,7 @@ std::deque MessageStore::getChannelMessages(uint8_t channel) cons { std::deque result; for (const auto &m : liveMessages) { - if (m.type == MessageType::BROADCAST && m.channelIndex == channel) { + if (isMessageVisible(m) && m.type == MessageType::BROADCAST && m.channelIndex == channel) { result.push_back(m); } } @@ -471,13 +514,22 @@ std::deque MessageStore::getDirectMessages() const { std::deque result; for (const auto &m : liveMessages) { - if (m.type == MessageType::DM_TO_US) { + if (isMessageVisible(m) && m.type == MessageType::DM_TO_US) { result.push_back(m); } } return result; } +bool MessageStore::hasVisibleMessages() const +{ + for (const auto &m : liveMessages) { + if (isMessageVisible(m)) + return true; + } + return false; +} + // Upgrade boot-relative timestamps once RTC is valid // Only same-boot boot-relative messages are healed. // Persisted boot-relative messages from old boots stay ??? forever. diff --git a/src/MessageStore.h b/src/MessageStore.h index 040806197..366c1a37d 100644 --- a/src/MessageStore.h +++ b/src/MessageStore.h @@ -93,10 +93,8 @@ class MessageStore void addLiveMessage(StoredMessage &&msg); void addLiveMessage(const StoredMessage &msg); // convenience overload const std::deque &getLiveMessages() const { return liveMessages; } - - // Add new messages from packets or manual input - const StoredMessage &addFromPacket(const meshtastic_MeshPacket &mp); // Incoming/outgoing โ†’ RAM only - void addFromString(uint32_t sender, uint8_t channelIndex, const std::string &text); // Manual add + // Add new messages from packets. Returns nullptr if the packet is filtered out. + const StoredMessage *tryAddFromPacket(const meshtastic_MeshPacket &mp); // Incoming/outgoing -> RAM only // Persistence methods (used only on boot/shutdown) void saveToFlash(); // Save messages to flash @@ -111,13 +109,16 @@ class MessageStore void deleteOldestMessageWithPeer(uint32_t peer); void deleteAllMessagesInChannel(uint8_t channel); void deleteAllMessagesWithPeer(uint32_t peer); - + void deleteAllMessagesFromNode(uint32_t nodeNum); // Unified accessor (for UI code, defaults to RAM buffer) const std::deque &getMessages() const { return liveMessages; } + bool hasVisibleMessages() const; // Helper filters for future use std::deque getChannelMessages(uint8_t channel) const; // Only broadcast messages on a channel std::deque getDirectMessages() const; // Only direct messages + bool shouldStorePacket(const meshtastic_MeshPacket &mp) const; + bool isMessageVisible(const StoredMessage &msg) const; // Upgrade boot-relative timestamps once RTC is valid void upgradeBootRelativeTimestamps(); @@ -129,6 +130,7 @@ class MessageStore static uint16_t storeText(const char *src, size_t len); private: + bool pruneHiddenMessages(); std::deque liveMessages; // Single in-RAM message buffer (also used for persistence) std::string filename; // Flash filename for persistence }; diff --git a/src/Power.cpp b/src/Power.cpp index 4118dac53..3a2bea10c 100644 --- a/src/Power.cpp +++ b/src/Power.cpp @@ -868,7 +868,6 @@ void Power::reboot() Wire.end(); Serial1.end(); if (screen) { - delete screen; screen = nullptr; } LOG_DEBUG("final reboot!"); diff --git a/src/SerialConsole.cpp b/src/SerialConsole.cpp index b32242212..93219d2fc 100644 --- a/src/SerialConsole.cpp +++ b/src/SerialConsole.cpp @@ -57,16 +57,6 @@ void consoleInit() DEBUG_PORT.rpInit(); // Simply sets up semaphore } -/// Print and flush an unclassified formatted console message. -void consolePrintf(const char *format, ...) -{ - va_list arg; - va_start(arg, format); - console->vprintf(nullptr, format, arg); - va_end(arg); - console->flush(); -} - /// Initialize console, protobuf transport, serial port, and worker thread state. SerialConsole::SerialConsole() : StreamAPI(&Port), RedirectablePrint(&Port), concurrency::OSThread("SerialConsole") { diff --git a/src/SerialConsole.h b/src/SerialConsole.h index 29614e0bc..eeed25644 100644 --- a/src/SerialConsole.h +++ b/src/SerialConsole.h @@ -67,7 +67,6 @@ class SerialConsole : public StreamAPI, public RedirectablePrint, private concur }; // A simple wrapper to allow non class aware code write to the console -void consolePrintf(const char *format, ...); void consoleInit(); extern SerialConsole *console; \ No newline at end of file diff --git a/src/buzz/buzz.cpp b/src/buzz/buzz.cpp index 6692d996d..42b9900bf 100644 --- a/src/buzz/buzz.cpp +++ b/src/buzz/buzz.cpp @@ -194,18 +194,6 @@ void playBoop() playTones(melody, sizeof(melody) / sizeof(ToneDuration)); } -void playLongPressLeadUp() -{ - // An ascending lead-up sequence for long press - builds anticipation - ToneDuration melody[] = { - {NOTE_C3, 100}, // Start low - {NOTE_E3, 100}, // Step up - {NOTE_G3, 100}, // Keep climbing - {NOTE_B3, 150} // Peak with longer note for emphasis - }; - playTones(melody, sizeof(melody) / sizeof(ToneDuration)); -} - // Static state for progressive lead-up notes static int leadUpNoteIndex = 0; static const ToneDuration leadUpNotes[] = { diff --git a/src/buzz/buzz.h b/src/buzz/buzz.h index 1b97e24de..2b6ac5022 100644 --- a/src/buzz/buzz.h +++ b/src/buzz/buzz.h @@ -12,6 +12,5 @@ void play4ClickUp(); void playBoop(); void playChirp(); void playClick(); -void playLongPressLeadUp(); bool playNextLeadUpNote(); // Play the next note in the lead-up sequence void resetLeadUpSequence(); // Reset the lead-up sequence to start from beginning \ No newline at end of file diff --git a/src/detect/ReClockI2C.h b/src/detect/ReClockI2C.h index c8a81fb09..503f1a48b 100644 --- a/src/detect/ReClockI2C.h +++ b/src/detect/ReClockI2C.h @@ -18,7 +18,7 @@ Only for cases where we can know it (ESP32 or known screen) we can do this. */ -extern graphics::Screen *screen; +extern std::unique_ptr screen; class ReClockI2C { diff --git a/src/detect/ScanI2CTwoWire.cpp b/src/detect/ScanI2CTwoWire.cpp index 151e559f1..c543aac85 100644 --- a/src/detect/ScanI2CTwoWire.cpp +++ b/src/detect/ScanI2CTwoWire.cpp @@ -220,6 +220,44 @@ bool detectSHT21SerialNumber(TwoWire *i2cBus, uint8_t address) } #endif +// Everest Semiconductor ES7210 4-channel audio ADC, as found on the T-Deck. Its AD1/AD0 straps +// select 0x40..0x43, so it lands squarely on the INA/SHT2X addresses. Chip ID registers and their +// reset values, per the ES7210 datasheet rev 2.0. +static constexpr uint8_t ES7210_CHIP_ID1_REG = 0x3D; +static constexpr uint8_t ES7210_CHIP_ID1 = 0x72; +static constexpr uint8_t ES7210_CHIP_ID0_REG = 0x3E; +static constexpr uint8_t ES7210_CHIP_ID0 = 0x10; + +static bool readByteRegister(TwoWire *i2cBus, uint8_t address, uint8_t reg, uint8_t &value) +{ + i2cBus->beginTransmission(address); + i2cBus->write(reg); + + if (i2cBus->endTransmission() != 0) + return false; + + if (i2cBus->requestFrom(address, (uint8_t)1) != 1 || !i2cBus->available()) + return false; + + value = i2cBus->read(); + return true; +} + +// The INA219 has no manufacturer or die ID register to check, so it is inferred from "something +// answered here and it wasn't anything else we know". That makes positively identifying the other +// occupants of this address the only way to keep them out of the fallback. +static bool detectES7210(TwoWire *i2cBus, uint8_t address) +{ + uint8_t id = 0; + + // Read the high ID byte first so anything that clearly isn't an ES7210 costs a single + // transaction, then confirm with the low byte. + if (!readByteRegister(i2cBus, address, ES7210_CHIP_ID1_REG, id) || id != ES7210_CHIP_ID1) + return false; + + return readByteRegister(i2cBus, address, ES7210_CHIP_ID0_REG, id) && id == ES7210_CHIP_ID0; +} + #define SCAN_SIMPLE_CASE(ADDR, T, ...) \ case ADDR: \ logFoundDevice(__VA_ARGS__); \ @@ -472,13 +510,23 @@ void ScanI2CTwoWire::scanPort(I2CPort port, uint8_t *address, uint8_t asize) type = INA260; } } + + // The ES7210 audio ADC shares this address on some boards (T-Deck). It answers + // none of the checks above, so identify it here and leave the type unset - driving + // an audio codec as if it were a power monitor crashes the device. See #11115. + if (type == NONE && detectES7210(i2cBus, (uint8_t)addr.address)) { + LOG_INFO("ES7210 audio codec at 0x%x, not a power sensor", (uint8_t)addr.address); + break; + } #if HAS_TELEMETRY && !MESHTASTIC_EXCLUDE_ENVIRONMENTAL_SENSOR if (type == NONE && detectSHT21SerialNumber(i2cBus, (uint8_t)addr.address)) { logFoundDevice("SHTXX (SHT2X)", (uint8_t)addr.address); type = SHTXX; } #endif - else { // Assume INA219 if none of the above ones are found + // Guarded on the type rather than chained to the SHT2X check above, so that a + // positively identified INA226/INA260 isn't overwritten right after being found. + if (type == NONE) { // Assume INA219 if none of the above ones are found logFoundDevice("INA219", (uint8_t)addr.address); type = INA219; } @@ -857,15 +905,27 @@ void ScanI2CTwoWire::scanPort(I2CPort port, uint8_t *address, uint8_t asize) break; case 0x48: { - i2cBus->beginTransmission(addr.address); - uint8_t getInfo[] = {0x5A, 0xC0, 0x00, 0xFF, 0xFC}; - uint8_t expectedInfo[] = {0xa5, 0xE0, 0x00, 0x3F, 0x19}; - uint8_t info[5]; + // T=1oI2C soft reset; an SE050 answers A5 E0 00 3F 19. requestFrom() is + // required: readBytes() only drains the RX buffer requestFrom() fills. + const uint8_t getInfo[] = {0x5A, 0xC0, 0x00, 0xFF, 0xFC}; + const uint8_t expectedInfo[] = {0xA5, 0xE0, 0x00, 0x3F, 0x19}; + uint8_t info[sizeof(expectedInfo)] = {0}; size_t len = 0; - i2cBus->write(getInfo, 5); - i2cBus->endTransmission(); - len = i2cBus->readBytes(info, 5); - if (len == 5 && memcmp(expectedInfo, info, len) == 0) { + bool isSE050 = false; + + i2cBus->beginTransmission(addr.address); + i2cBus->write(getInfo, sizeof(getInfo)); + if (i2cBus->endTransmission() == 0) { + delay(2); // guard time before the answer can be read back + len = i2cBus->requestFrom((uint8_t)addr.address, (uint8_t)sizeof(info)); + if (len == sizeof(info)) { + for (size_t i = 0; i < sizeof(info); i++) + info[i] = i2cBus->read(); + isSE050 = (memcmp(expectedInfo, info, sizeof(info)) == 0); + } + } + + if (isSE050) { LOG_INFO("NXP SE050 crypto chip found"); type = NXP_SE050; break; diff --git a/src/gps/GPS.cpp b/src/gps/GPS.cpp index 0a942f10d..389fa6006 100644 --- a/src/gps/GPS.cpp +++ b/src/gps/GPS.cpp @@ -2201,11 +2201,6 @@ bool GPS::hasLock() return false; } -bool GPS::hasFlow() -{ - return reader.passedChecksum() > 0; -} - bool GPS::whileActive() { unsigned int charsInBuf = 0; diff --git a/src/gps/GPS.h b/src/gps/GPS.h index 678cc2845..328a9a316 100644 --- a/src/gps/GPS.h +++ b/src/gps/GPS.h @@ -111,9 +111,6 @@ class GPS : private concurrency::OSThread /// Returns true if we have acquired GPS lock. virtual bool hasLock(); - /// Returns true if there's valid data flow with the chip. - virtual bool hasFlow(); - /// Return true if we are connected to a GPS bool isConnected() const { return hasGPS; } diff --git a/src/gps/GeoCoord.cpp b/src/gps/GeoCoord.cpp index 8324bc3c0..4afae9394 100644 --- a/src/gps/GeoCoord.cpp +++ b/src/gps/GeoCoord.cpp @@ -496,34 +496,6 @@ float GeoCoord::rangeMetersToRadians(double range_meters) return (PI / (180 * 60)) * distance_nm; } -/** - * Ported from http://www.edwilliams.org/avform147.htm#Intro - * @brief Convert from radians to range in meters on a great circle - * @param range_radians - * The range in radians - * @return Range in meters on a great circle - */ -float GeoCoord::rangeRadiansToMeters(double range_radians) -{ - double distance_nm = ((180 * 60) / PI) * range_radians; - // 1 meter is 0.000539957 nm - return distance_nm * 0.000539957; -} - -// Find distance from point to passed in point -int32_t GeoCoord::distanceTo(const GeoCoord &pointB) -{ - return latLongToMeter(this->getLatitude() * 1e-7, this->getLongitude() * 1e-7, pointB.getLatitude() * 1e-7, - pointB.getLongitude() * 1e-7); -} - -// Find bearing from point to passed in point -int32_t GeoCoord::bearingTo(const GeoCoord &pointB) -{ - return bearing(this->getLatitude() * 1e-7, this->getLongitude() * 1e-7, pointB.getLatitude() * 1e-7, - pointB.getLongitude() * 1e-7); -} - /** * Create a new point based on the passed-in point * Ported from http://www.edwilliams.org/avform147.htm#LL diff --git a/src/gps/GeoCoord.h b/src/gps/GeoCoord.h index 3bb6606f2..5afa78430 100644 --- a/src/gps/GeoCoord.h +++ b/src/gps/GeoCoord.h @@ -103,7 +103,6 @@ class GeoCoord static void convertWGS84ToOSGB36(const double lat, const double lon, double &osgb_Latitude, double &osgb_Longitude); static float latLongToMeter(double lat_a, double lng_a, double lat_b, double lng_b); static float bearing(double lat1, double lon1, double lat2, double lon2); - static float rangeRadiansToMeters(double range_radians); static float rangeMetersToRadians(double range_meters); static unsigned int bearingToDegrees(const char *bearing); static const char *degreesToBearing(unsigned int degrees); @@ -114,8 +113,6 @@ class GeoCoord static double toDegrees(double r); // Point to point conversions - int32_t distanceTo(const GeoCoord &pointB); - int32_t bearingTo(const GeoCoord &pointB); std::shared_ptr pointAtDistance(double bearing, double range); // Lat lon alt getters diff --git a/src/gps/RTC.cpp b/src/gps/RTC.cpp index 400bfd1aa..94288529e 100644 --- a/src/gps/RTC.cpp +++ b/src/gps/RTC.cpp @@ -319,7 +319,10 @@ RTCSetResult perhapsSetRTC(RTCQuality q, const struct timeval *tv, bool forceUpd #else rtc.initI2C(); #endif - tm *t = gmtime(&tv->tv_sec); + // tv_sec is a long, which is not time_t everywhere: on Windows + // time_t is 64-bit while long is 32-bit. Copy before taking &. + time_t setSecs = tv->tv_sec; + tm *t = gmtime(&setSecs); rtc.setTime(t->tm_year + 1900, t->tm_mon + 1, t->tm_wday, t->tm_mday, t->tm_hour, t->tm_min, t->tm_sec); LOG_DEBUG("RV3028_RTC setTime %02d-%02d-%02d %02d:%02d:%02d (%ld)", t->tm_year + 1900, t->tm_mon + 1, t->tm_mday, t->tm_hour, t->tm_min, t->tm_sec, printableEpoch); @@ -341,7 +344,10 @@ RTCSetResult perhapsSetRTC(RTCQuality q, const struct timeval *tv, bool forceUpd #else rtc.begin(Wire); #endif - tm *t = gmtime(&tv->tv_sec); + // tv_sec is a long, which is not time_t everywhere: on Windows + // time_t is 64-bit while long is 32-bit. Copy before taking &. + time_t setSecs = tv->tv_sec; + tm *t = gmtime(&setSecs); rtc.setDateTime(*t); LOG_DEBUG("%s setDateTime %02d-%02d-%02d %02d:%02d:%02d (%ld)", rtc.getChipName(), t->tm_year + 1900, t->tm_mon + 1, t->tm_mday, t->tm_hour, t->tm_min, t->tm_sec, printableEpoch); @@ -355,7 +361,10 @@ RTCSetResult perhapsSetRTC(RTCQuality q, const struct timeval *tv, bool forceUpd #else ArtronShop_RX8130CE rtc(&Wire); #endif - tm *t = gmtime(&tv->tv_sec); + // tv_sec is a long, which is not time_t everywhere: on Windows + // time_t is 64-bit while long is 32-bit. Copy before taking &. + time_t setSecs = tv->tv_sec; + tm *t = gmtime(&setSecs); if (rtc.setTime(*t)) { LOG_DEBUG("RX8130CE setDateTime %02d-%02d-%02d %02d:%02d:%02d (%ld)", t->tm_year + 1900, t->tm_mon + 1, t->tm_mday, t->tm_hour, t->tm_min, t->tm_sec, printableEpoch); diff --git a/src/graphics/EInkParallelDisplay.cpp b/src/graphics/EInkParallelDisplay.cpp index 293f57e81..552b11747 100644 --- a/src/graphics/EInkParallelDisplay.cpp +++ b/src/graphics/EInkParallelDisplay.cpp @@ -77,12 +77,13 @@ EInkParallelDisplay::~EInkParallelDisplay() bool EInkParallelDisplay::connect() { LOG_INFO("Do EPD init"); + int initRc = BBEP_SUCCESS; if (!epaper) { epaper = new FASTEPD; #if defined(T5_S3_EPAPER_PRO_V1) - epaper->initPanel(BB_PANEL_LILYGO_T5PRO, 28000000); + initRc = epaper->initPanel(BB_PANEL_LILYGO_T5PRO, 28000000); #elif defined(T5_S3_EPAPER_PRO_V2) - epaper->initPanel(BB_PANEL_LILYGO_T5PRO_V2, 28000000); + initRc = epaper->initPanel(BB_PANEL_LILYGO_T5PRO_V2, 28000000); // initialize all port 0 pins (0-7) as outputs / HIGH for (int i = 0; i < 8; i++) { epaper->ioPinMode(i, OUTPUT); @@ -93,6 +94,16 @@ bool EInkParallelDisplay::connect() #endif } + // FastEPD allocates its framebuffer only from PSRAM; if PSRAM init failed the alloc returns + // NULL and initPanel() returns an error, so clearWhite() below would memset(NULL). Skip EInk + // bring-up but return true so OLEDDisplay still allocates its base buffer (base draw ops stay + // safe); displayReady stays false so the FastEPD push paths no-op -> node runs headless. + if (initRc != BBEP_SUCCESS || epaper->currentBuffer() == nullptr) { + LOG_ERROR("EPD framebuffer unavailable (initPanel rc=%d, PSRAM=%u); running headless", initRc, + (unsigned)ESP.getPsramSize()); + return true; + } + // epaper->setRotation(rotation); // does not work, messes up width/height epaper->setMode(BB_MODE_1BPP); epaper->clearWhite(); @@ -103,6 +114,7 @@ bool EInkParallelDisplay::connect() resetGhostPixelTracking(); #endif + displayReady = true; return true; } @@ -187,6 +199,9 @@ void EInkParallelDisplay::asyncFullUpdateTask(void *pvParameters) */ void EInkParallelDisplay::display(void) { + if (!displayReady) // no framebuffer (PSRAM absent / init failed) -> nothing to push + return; + const uint16_t w = this->displayWidth; const uint16_t h = this->displayHeight; @@ -400,6 +415,9 @@ void EInkParallelDisplay::resetGhostPixelTracking() */ bool EInkParallelDisplay::forceDisplay(uint32_t msecLimit) { + if (!displayReady) + return false; + uint32_t now = millis(); if (lastDrawMsec == 0 || (now - lastDrawMsec) > msecLimit) { display(); @@ -410,6 +428,8 @@ bool EInkParallelDisplay::forceDisplay(uint32_t msecLimit) void EInkParallelDisplay::endUpdate() { + if (!displayReady) + return; { // ensure any async full update is started/completed if (asyncFullRunning.load()) { diff --git a/src/graphics/EInkParallelDisplay.h b/src/graphics/EInkParallelDisplay.h index 81189e400..60ef7f20c 100644 --- a/src/graphics/EInkParallelDisplay.h +++ b/src/graphics/EInkParallelDisplay.h @@ -40,6 +40,9 @@ class EInkParallelDisplay : public OLEDDisplay uint32_t lastDrawMsec = 0; FASTEPD *epaper; + // Set only when connect() fully succeeds; framebuffer-touching methods no-op while false. + bool displayReady = false; + private: // Async full-refresh support std::atomic asyncFullRunning{false}; diff --git a/src/graphics/HUB75Display.cpp b/src/graphics/HUB75Display.cpp index 36ca64f39..f1b0528f1 100644 --- a/src/graphics/HUB75Display.cpp +++ b/src/graphics/HUB75Display.cpp @@ -41,9 +41,8 @@ bool HUB75Display::connect() cfg.i2sspeed = HUB75_I2S_CFG::HZ_8M; // timing headroom (signal integrity) cfg.latch_blanking = 4; // clean row transitions cfg.clkphase = false; // inverted clock phase: fixes 1px skew of lower half - cfg.double_buff = false; // single buffer: halves the internal-SRAM DMA - // footprint. display() draws directly into the - // live buffer with a dirty-diff + cfg.double_buff = false; // single buffer halves the internal-SRAM DMA + // footprint; display() dirty-diffs into the live buffer matrix = new MatrixPanel_I2S_DMA(cfg); bool ok = matrix->begin(); diff --git a/src/graphics/Screen.cpp b/src/graphics/Screen.cpp index 73bf04b6d..7892cbdda 100644 --- a/src/graphics/Screen.cpp +++ b/src/graphics/Screen.cpp @@ -1846,40 +1846,6 @@ void Screen::handleStartFirmwareUpdateScreen() setFrameImmediateDraw(frames); } -void Screen::blink() -{ -#ifdef MESHTASTIC_LOCKDOWN - // L4: defensive guard. blink() paints arbitrary geometry, not node - // data, so it doesn't actually leak today. But it bypasses the normal - // ui->update() path that the lockdown short-circuit gates, so any - // future change that puts content into blink would silently leak past - // redaction. Refuse to draw when the redaction latch is set. - if (meshtastic_security::shouldRedactDisplay()) - return; -#endif - setFastFramerate(); - uint8_t count = 10; - dispdev->setBrightness(254); - while (count > 0) { - dispdev->fillRect(0, 0, dispdev->getWidth(), dispdev->getHeight()); -#if GRAPHICS_TFT_COLORING_ENABLED - prepareFrameColorRegions(); -#endif - dispdev->display(); - delay(50); - dispdev->clear(); -#if GRAPHICS_TFT_COLORING_ENABLED - prepareFrameColorRegions(); -#endif - dispdev->display(); - delay(50); - count = count - 1; - } - // The dispdev->setBrightness does not work for t-deck display, it seems to run the setBrightness function in - // OLEDDisplay. - dispdev->setBrightness(brightness); -} - void Screen::increaseBrightness() { brightness = ((brightness + 62) > 254) ? brightness : (brightness + 62); @@ -2110,7 +2076,7 @@ int Screen::handleInputEvent(const InputEvent *event) if (ui->getUiState()->currentFrame == framesetInfo.positions.textMessage) { if (event->inputEvent == INPUT_BROKER_UP) { - if (messageStore.getMessages().empty()) { + if (!messageStore.hasVisibleMessages()) { cannedMessageModule->LaunchWithDestination(NODENUM_BROADCAST); } else { graphics::MessageRenderer::scrollUp(); @@ -2120,7 +2086,7 @@ int Screen::handleInputEvent(const InputEvent *event) } if (event->inputEvent == INPUT_BROKER_DOWN) { - if (messageStore.getMessages().empty()) { + if (!messageStore.hasVisibleMessages()) { cannedMessageModule->LaunchWithDestination(NODENUM_BROADCAST); } else { graphics::MessageRenderer::scrollDown(); @@ -2169,9 +2135,9 @@ int Screen::handleInputEvent(const InputEvent *event) if (!inputIntercepted) { #if defined(INPUTDRIVER_ENCODER_TYPE) && INPUTDRIVER_ENCODER_TYPE == 2 bool handledEncoderScroll = false; - const bool isTextMessageFrame = (framesetInfo.positions.textMessage != 255 && - this->ui->getUiState()->currentFrame == framesetInfo.positions.textMessage && - !messageStore.getMessages().empty()); + const bool isTextMessageFrame = + (framesetInfo.positions.textMessage != 255 && + this->ui->getUiState()->currentFrame == framesetInfo.positions.textMessage && messageStore.hasVisibleMessages()); if (isTextMessageFrame) { if (event->inputEvent == INPUT_BROKER_UP_LONG) { graphics::MessageRenderer::nudgeScroll(-1); @@ -2254,7 +2220,7 @@ int Screen::handleInputEvent(const InputEvent *event) } else if (this->ui->getUiState()->currentFrame == framesetInfo.positions.lora) { menuHandler::loraMenu(); } else if (this->ui->getUiState()->currentFrame == framesetInfo.positions.textMessage) { - if (!messageStore.getMessages().empty()) { + if (messageStore.hasVisibleMessages()) { menuHandler::messageResponseMenu(); } else { if (currentResolution == ScreenResolution::UltraLow) { diff --git a/src/graphics/Screen.h b/src/graphics/Screen.h index 4aeec6ce8..776a14c58 100644 --- a/src/graphics/Screen.h +++ b/src/graphics/Screen.h @@ -6,6 +6,7 @@ #include "mesh/generated/meshtastic/config.pb.h" #include #include +#include #include #include @@ -310,8 +311,6 @@ class Screen : public concurrency::OSThread */ void doDeepSleep(); - void blink(); - // Draw north float estimatedHeading(double lat, double lon); @@ -844,6 +843,6 @@ class Screen : public concurrency::OSThread // Extern declarations for function symbols used in UIRenderer extern std::vector functionSymbol; extern std::string functionSymbolString; -extern graphics::Screen *screen; +extern std::unique_ptr screen; #endif diff --git a/src/graphics/VirtualKeyboard.cpp b/src/graphics/VirtualKeyboard.cpp index 43b33e853..fd06e0def 100644 --- a/src/graphics/VirtualKeyboard.cpp +++ b/src/graphics/VirtualKeyboard.cpp @@ -718,11 +718,6 @@ void VirtualKeyboard::setInputText(const std::string &text) inputText = text; } -std::string VirtualKeyboard::getInputText() const -{ - return inputText; -} - void VirtualKeyboard::setHeader(const std::string &header) { headerText = header; diff --git a/src/graphics/VirtualKeyboard.h b/src/graphics/VirtualKeyboard.h index 169163b57..250ae61f8 100644 --- a/src/graphics/VirtualKeyboard.h +++ b/src/graphics/VirtualKeyboard.h @@ -27,7 +27,6 @@ class VirtualKeyboard void draw(OLEDDisplay *display, int16_t offsetX, int16_t offsetY); void setInputText(const std::string &text); - std::string getInputText() const; void setHeader(const std::string &header); void setCallback(std::function callback); diff --git a/src/graphics/draw/DebugRenderer.cpp b/src/graphics/draw/DebugRenderer.cpp index b4dd5ab8a..95a76c0e9 100644 --- a/src/graphics/draw/DebugRenderer.cpp +++ b/src/graphics/draw/DebugRenderer.cpp @@ -38,7 +38,7 @@ using namespace meshtastic; // External variables -extern graphics::Screen *screen; +extern std::unique_ptr screen; extern PowerStatus *powerStatus; extern NodeStatus *nodeStatus; extern GPSStatus *gpsStatus; @@ -230,131 +230,7 @@ void drawFrameWiFi(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, i #endif } -void drawFrameSettings(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y) -{ - display->setFont(FONT_SMALL); - - // The coordinates define the left starting point of the text - display->setTextAlignment(TEXT_ALIGN_LEFT); - - if (config.display.displaymode != meshtastic_Config_DisplayConfig_DisplayMode_INVERTED) { - display->fillRect(0 + x, 0 + y, x + display->getWidth(), y + FONT_HEIGHT_SMALL); - display->setColor(BLACK); - } - - char batStr[20]; - if (powerStatus->getHasBattery()) { - int batV = powerStatus->getBatteryVoltageMv() / 1000; - int batCv = (powerStatus->getBatteryVoltageMv() % 1000) / 10; - - snprintf(batStr, sizeof(batStr), "B %01d.%02dV %3d%% %c%c", batV, batCv, powerStatus->getBatteryChargePercent(), - powerStatus->getIsCharging() ? '+' : ' ', powerStatus->getHasUSB() ? 'U' : ' '); - - // Line 1 - display->drawString(x, y, batStr); - if (config.display.heading_bold) - display->drawString(x + 1, y, batStr); - } else { - // Line 1 - display->drawString(x, y, "USB"); - if (config.display.heading_bold) - display->drawString(x + 1, y, "USB"); - } - - uint32_t currentMillis = millis(); - uint32_t seconds = currentMillis / 1000; - uint32_t minutes = seconds / 60; - uint32_t hours = minutes / 60; - uint32_t days = hours / 24; - // currentMillis %= 1000; - // seconds %= 60; - // minutes %= 60; - // hours %= 24; - - // Show uptime as days, hours, minutes OR seconds - std::string uptime = UIRenderer::drawTimeDelta(days, hours, minutes, seconds); - - // Line 1 (Still) - if (currentResolution != graphics::ScreenResolution::UltraLow) { - display->drawString(x + SCREEN_WIDTH - display->getStringWidth(uptime.c_str()), y, uptime.c_str()); - if (config.display.heading_bold) - display->drawString(x - 1 + SCREEN_WIDTH - display->getStringWidth(uptime.c_str()), y, uptime.c_str()); - - display->setColor(WHITE); - } - // Setup string to assemble analogClock string - std::string analogClock = ""; - - uint32_t rtc_sec = getValidTime(RTCQuality::RTCQualityDevice, true); // Display local timezone - if (rtc_sec > 0) { - long hms = rtc_sec % SEC_PER_DAY; - // hms += tz.tz_dsttime * SEC_PER_HOUR; - // hms -= tz.tz_minuteswest * SEC_PER_MIN; - // mod `hms` to ensure in positive range of [0...SEC_PER_DAY) - hms = (hms + SEC_PER_DAY) % SEC_PER_DAY; - - // Tear apart hms into h:m:s - int hour, min, sec; - graphics::decomposeTime(rtc_sec, hour, min, sec); - - char timebuf[12]; - - if (config.display.use_12h_clock) { - std::string meridiem = "am"; - if (hour >= 12) { - if (hour > 12) - hour -= 12; - meridiem = "pm"; - } - if (hour == 00) { - hour = 12; - } - snprintf(timebuf, sizeof(timebuf), "%d:%02d:%02d%s", hour, min, sec, meridiem.c_str()); - } else { - snprintf(timebuf, sizeof(timebuf), "%02d:%02d:%02d", hour, min, sec); - } - analogClock += timebuf; - } - - // Line 2 - display->drawString(x, y + FONT_HEIGHT_SMALL * 1, analogClock.c_str()); - - // Display Channel Utilization - char chUtil[13]; - snprintf(chUtil, sizeof(chUtil), "ChUtil %2.0f%%", airTime->channelUtilizationPercent()); - display->drawString(x + SCREEN_WIDTH - display->getStringWidth(chUtil), y + FONT_HEIGHT_SMALL * 1, chUtil); - -#if HAS_GPS - if (config.position.gps_mode == meshtastic_Config_PositionConfig_GpsMode_ENABLED) { - // Line 3 - if (uiconfig.gps_format != meshtastic_DeviceUIConfig_GpsCoordinateFormat_DMS) // if DMS then don't draw altitude - UIRenderer::drawGpsAltitude(display, x, y + FONT_HEIGHT_SMALL * 2, gpsStatus); - - // Line 4 - UIRenderer::drawGpsCoordinates(display, x, y + FONT_HEIGHT_SMALL * 3, gpsStatus); - } else { - UIRenderer::drawGpsPowerStatus(display, x, y + FONT_HEIGHT_SMALL * 2, gpsStatus); - } -#endif -/* Display a heartbeat pixel that blinks every time the frame is redrawn */ -#ifdef SHOW_REDRAWS - if (heartbeat) - display->setPixel(0, 0); - heartbeat = !heartbeat; -#endif -} - // Trampoline functions for DebugInfo class access -void drawDebugInfoTrampoline(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y) -{ - drawFrame(display, state, x, y); -} - -void drawDebugInfoSettingsTrampoline(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y) -{ - drawFrameSettings(display, state, x, y); -} - void drawDebugInfoWiFiTrampoline(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y) { drawFrameWiFi(display, state, x, y); diff --git a/src/graphics/draw/DebugRenderer.h b/src/graphics/draw/DebugRenderer.h index 65fa74ca6..9d28a5e9c 100644 --- a/src/graphics/draw/DebugRenderer.h +++ b/src/graphics/draw/DebugRenderer.h @@ -20,12 +20,9 @@ namespace DebugRenderer { // Debug frame functions void drawFrame(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y); -void drawFrameSettings(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y); void drawFrameWiFi(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y); // Trampoline functions for framework callback compatibility -void drawDebugInfoTrampoline(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y); -void drawDebugInfoSettingsTrampoline(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y); void drawDebugInfoWiFiTrampoline(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y); // LoRa information display diff --git a/src/graphics/draw/MenuHandler.cpp b/src/graphics/draw/MenuHandler.cpp index 349fe9c4a..b65917dad 100644 --- a/src/graphics/draw/MenuHandler.cpp +++ b/src/graphics/draw/MenuHandler.cpp @@ -70,12 +70,15 @@ const StoredMessage *getNewestMessageForActiveThread() const uint32_t peer = graphics::MessageRenderer::getThreadPeer(); const uint32_t localNode = nodeDB->getNodeNum(); - if (mode == graphics::MessageRenderer::ThreadMode::ALL) { - return &messages.back(); - } - for (auto it = messages.rbegin(); it != messages.rend(); ++it) { const StoredMessage &m = *it; + if (!messageStore.isMessageVisible(m)) { + continue; + } + + if (mode == graphics::MessageRenderer::ThreadMode::ALL) { + return &m; + } if (mode == graphics::MessageRenderer::ThreadMode::CHANNEL) { if (m.type == MessageType::BROADCAST && static_cast(m.channelIndex) == channel) { @@ -282,21 +285,19 @@ void menuHandler::LoraRegionPicker(uint32_t duration) return; } - // Guard: without a reboot, reconfigure() applies the region directly, so reject - // regions this node can't use up front: unrecognized codes, licensed-only regions, - // and radio hardware mismatches (2.4 GHz vs sub-GHz) - the same checks the admin - // set-config path applies, but side-effect-free: ignoring a menu selection should - // not record a critical error or notify clients. getRadio() used to catch hardware - // mismatches post-reboot only. + const RegionInfo *selectedRegionInfo = getRegion(selectedRegion); + bool hamMode = selectedRegionInfo->code == selectedRegion && selectedRegionInfo->profile && + selectedRegionInfo->profile->licensedOnly; + + // Validate radio compatibility for a prospective Ham region before confirmation. auto candidateLora = config.lora; candidateLora.region = selectedRegion; - char regionErr[160]; - if (!RadioInterface::checkConfigRegion(candidateLora, regionErr, sizeof(regionErr))) { + char regionErr[160] = {}; + if (!RadioInterface::checkConfigRegion(candidateLora, regionErr, sizeof(regionErr), hamMode)) { LOG_WARN("Ignoring region selection: %s", regionErr); return; } - bool hamMode = getRegion(selectedRegion)->profile->licensedOnly; if (hamMode) { LOG_INFO("User chose an amateur radio mode region"); pendingRegion = selectedRegion; diff --git a/src/graphics/draw/MessageRenderer.cpp b/src/graphics/draw/MessageRenderer.cpp index bc5e027fb..932dc773e 100644 --- a/src/graphics/draw/MessageRenderer.cpp +++ b/src/graphics/draw/MessageRenderer.cpp @@ -17,12 +17,13 @@ #include "graphics/emotes.h" #include "main.h" #include "meshUtils.h" +#include "modules/CannedMessageModule.h" #include #include // External declarations extern bool hasUnreadMessage; -extern graphics::Screen *screen; +extern std::unique_ptr screen; using graphics::Emote; using graphics::emotes; @@ -403,6 +404,8 @@ void drawTextMessageFrame(OLEDDisplay *display, OLEDDisplayUiState *state, int16 // Filter messages based on thread mode std::deque filtered; for (const auto &m : messageStore.getLiveMessages()) { + if (!messageStore.isMessageVisible(m)) + continue; bool include = false; switch (currentMode) { case ThreadMode::ALL: @@ -1071,6 +1074,7 @@ void handleNewMessage(OLEDDisplay *display, const StoredMessage &sm, const mesht { if (packet.from != 0) { hasUnreadMessage = true; + const bool suppressBanner = cannedMessageModule && cannedMessageModule->isFreeTextActive(); // Determine if message belongs to a muted channel bool isChannelMuted = false; @@ -1161,11 +1165,13 @@ void handleNewMessage(OLEDDisplay *display, const StoredMessage &sm, const mesht // Shorter banner if already in a conversation (Channel or Direct) bool inThread = (getThreadMode() != ThreadMode::ALL); - if (shouldWakeOnReceivedMessage()) { + if (!suppressBanner && shouldWakeOnReceivedMessage()) { screen->setOn(true); } - screen->showSimpleBanner(banner, inThread ? 1000 : 3000); + if (!suppressBanner) { + screen->showSimpleBanner(banner, inThread ? 1000 : 3000); + } } // Always focus into the correct conversation thread when a message with real text arrives diff --git a/src/graphics/draw/NodeListRenderer.cpp b/src/graphics/draw/NodeListRenderer.cpp index dfe6671c8..5b4036a98 100644 --- a/src/graphics/draw/NodeListRenderer.cpp +++ b/src/graphics/draw/NodeListRenderer.cpp @@ -17,14 +17,8 @@ #include "meshUtils.h" #include -// Forward declarations for functions defined in Screen.cpp -namespace graphics -{ -extern bool haveGlyphs(const char *str); -} // namespace graphics - // Global screen instance -extern graphics::Screen *screen; +extern std::unique_ptr screen; #if defined(OLED_TINY) static uint32_t lastSwitchTime = 0; @@ -180,11 +174,6 @@ unsigned long getModeCycleIntervalMs() return 3000; } -int calculateMaxScroll(int totalEntries, int visibleRows) -{ - return max(0, (totalEntries - 1) / (visibleRows * 2)); -} - void drawColumnSeparator(OLEDDisplay *display, int16_t x, int16_t yStart, int16_t yEnd) { x = (currentResolution == ScreenResolution::High) ? x - 2 : (currentResolution == ScreenResolution::Low) ? x - 1 : x; @@ -910,29 +899,6 @@ void drawNodeListWithCompasses(OLEDDisplay *display, OLEDDisplayUiState *state, drawNodeListScreen(display, state, x, y, "Bearings", drawEntryCompass, drawCompassArrow, headingRadian, lat, lon); } -/// Draw a series of fields in a column, wrapping to multiple columns if needed -void drawColumns(OLEDDisplay *display, int16_t x, int16_t y, const char **fields) -{ - // The coordinates define the left starting point of the text - display->setTextAlignment(TEXT_ALIGN_LEFT); - - const char **f = fields; - int xo = x, yo = y; - while (*f) { - display->drawString(xo, yo, *f); - if ((display->getColor() == BLACK) && config.display.heading_bold) - display->drawString(xo + 1, yo, *f); - - display->setColor(WHITE); - yo += FONT_HEIGHT_SMALL; - if (yo > SCREEN_HEIGHT - FONT_HEIGHT_SMALL) { - xo += SCREEN_WIDTH / 2; - yo = 0; - } - f++; - } -} - } // namespace NodeListRenderer } // namespace graphics #endif diff --git a/src/graphics/draw/NodeListRenderer.h b/src/graphics/draw/NodeListRenderer.h index 4aa217141..69c4bc067 100644 --- a/src/graphics/draw/NodeListRenderer.h +++ b/src/graphics/draw/NodeListRenderer.h @@ -58,7 +58,6 @@ void drawNodeListWithCompasses(OLEDDisplay *display, OLEDDisplayUiState *state, const char *getCurrentModeTitle_Nodes(int screenWidth); const char *getCurrentModeTitle_Location(int screenWidth); std::string getSafeNodeName(OLEDDisplay *display, meshtastic_NodeInfoLite *node, int columnWidth); -void drawColumns(OLEDDisplay *display, int16_t x, int16_t y, const char **fields); // Scrolling controls void scrollUp(); diff --git a/src/graphics/draw/NotificationRenderer.cpp b/src/graphics/draw/NotificationRenderer.cpp index 98f229b83..74e26358f 100644 --- a/src/graphics/draw/NotificationRenderer.cpp +++ b/src/graphics/draw/NotificationRenderer.cpp @@ -207,8 +207,6 @@ void NotificationRenderer::resetBanner() alertBannerMessage[0] = '\0'; current_notification_type = notificationTypeEnum::none; - OnScreenKeyboardModule::instance().clearPopup(); - inEvent.inputEvent = INPUT_BROKER_NONE; inEvent.kbchar = 0; curSelected = 0; @@ -1187,9 +1185,6 @@ void NotificationRenderer::drawTextInput(OLEDDisplay *display, OLEDDisplayUiStat display->setColor(WHITE); // Draw the virtual keyboard virtualKeyboard->draw(display, 0, 0); - - // Draw transient popup overlay (if any) managed by OnScreenKeyboardModule - OnScreenKeyboardModule::instance().drawPopupOverlay(display); } else { // If virtualKeyboard is null, reset the banner to avoid getting stuck LOG_INFO("Virtual keyboard is null - resetting banner"); @@ -1202,12 +1197,5 @@ bool NotificationRenderer::isOverlayBannerShowing() return strlen(alertBannerMessage) > 0 && (alertBannerUntil == 0 || millis() <= alertBannerUntil); } -void NotificationRenderer::showKeyboardMessagePopupWithTitle(const char *title, const char *content, uint32_t durationMs) -{ - if (!title || !content || current_notification_type != notificationTypeEnum::text_input) - return; - OnScreenKeyboardModule::instance().showPopup(title, content, durationMs); -} - } // namespace graphics #endif diff --git a/src/graphics/draw/NotificationRenderer.h b/src/graphics/draw/NotificationRenderer.h index 08f6f74b0..360bfac3c 100644 --- a/src/graphics/draw/NotificationRenderer.h +++ b/src/graphics/draw/NotificationRenderer.h @@ -39,7 +39,6 @@ class NotificationRenderer static BannerFont alertBannerLineFonts[MAX_LINES + 1]; static void parseBannerMessageWithFonts(const char *message); static void resetBanner(); - static void showKeyboardMessagePopupWithTitle(const char *title, const char *content, uint32_t durationMs); static void drawBannercallback(OLEDDisplay *display, OLEDDisplayUiState *state); static void drawAlertBannerOverlay(OLEDDisplay *display, OLEDDisplayUiState *state); static void drawNumberPicker(OLEDDisplay *display, OLEDDisplayUiState *state); diff --git a/src/graphics/draw/UIRenderer.cpp b/src/graphics/draw/UIRenderer.cpp index 53f016645..aba2fb632 100644 --- a/src/graphics/draw/UIRenderer.cpp +++ b/src/graphics/draw/UIRenderer.cpp @@ -28,7 +28,7 @@ #include // External variables -extern graphics::Screen *screen; +extern std::unique_ptr screen; #if defined(OLED_TINY) static uint32_t lastSwitchTime = 0; #endif @@ -42,9 +42,9 @@ static inline void drawSatelliteIcon(OLEDDisplay *display, int16_t x, int16_t y) { int yOffset = (currentResolution == ScreenResolution::High) ? -5 : 1; if (currentResolution == ScreenResolution::High) { - NodeListRenderer::drawScaledXBitmap16x16(x, y + yOffset, imgSatellite_width, imgSatellite_height, imgSatellite, display); + NodeListRenderer::drawScaledXBitmap16x16(x, y + yOffset, imgGPS_width, imgGPS_height, imgGPS, display); } else { - display->drawXbm(x + 1, y + yOffset, imgSatellite_width, imgSatellite_height, imgSatellite); + display->drawXbm(x + 1, y + yOffset, imgGPS_width, imgGPS_height, imgGPS); } } @@ -521,9 +521,9 @@ void UIRenderer::drawGps(OLEDDisplay *display, int16_t x, int16_t y, const mesht { // Draw satellite image if (currentResolution == ScreenResolution::High) { - NodeListRenderer::drawScaledXBitmap16x16(x, y - 2, imgSatellite_width, imgSatellite_height, imgSatellite, display); + NodeListRenderer::drawScaledXBitmap16x16(x, y - 2, imgGPS_width, imgGPS_height, imgGPS, display); } else { - display->drawXbm(x + 1, y + 1, imgSatellite_width, imgSatellite_height, imgSatellite); + display->drawXbm(x + 1, y + 1, imgGPS_width, imgGPS_height, imgGPS); } char textString[10]; @@ -1386,30 +1386,6 @@ int UIRenderer::formatDateTime(char *buf, size_t bufSize, uint32_t rtc_sec, OLED return display->getStringWidth(buf); } -// Check if the display can render a string (detect special chars; emoji) -bool UIRenderer::haveGlyphs(const char *str) -{ -#if defined(OLED_PL) || defined(OLED_UA) || defined(OLED_RU) || defined(OLED_CS) - // Don't want to make any assumptions about custom language support - return true; -#endif - - // Check each character with the lookup function for the OLED library - // We're not really meant to use this directly.. - bool have = true; - for (uint16_t i = 0; i < strlen(str); i++) { - uint8_t result = Screen::customFontTableLookup((uint8_t)str[i]); - // If font doesn't support a character, it is substituted for ยฟ - if (result == 191 && (uint8_t)str[i] != 191) { - have = false; - break; - } - } - - // LOG_DEBUG("haveGlyphs=%d", have); - return have; -} - #ifdef USE_EINK /// Used on eink displays while in deep sleep void UIRenderer::drawDeepSleepFrame(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y) diff --git a/src/graphics/draw/UIRenderer.h b/src/graphics/draw/UIRenderer.h index 0aeace42e..1afddea8f 100644 --- a/src/graphics/draw/UIRenderer.h +++ b/src/graphics/draw/UIRenderer.h @@ -104,9 +104,6 @@ class UIRenderer { drawStringWithEmotes(display, x, y, line.c_str(), fontHeight, emoteSpacing, fauxBold); } - - // Check if the display can render a string (detect special chars; emoji) - static bool haveGlyphs(const char *str); }; // namespace UIRenderer } // namespace graphics diff --git a/src/graphics/images.h b/src/graphics/images.h index 86d9efb7b..3234ff566 100644 --- a/src/graphics/images.h +++ b/src/graphics/images.h @@ -11,6 +11,9 @@ const uint8_t SATELLITE_IMAGE[] PROGMEM = {0x00, 0x08, 0x00, 0x1C, 0x00, 0x0E, 0 const uint8_t imgSatellite[] PROGMEM = { 0b00000000, 0b00000000, 0b00000000, 0b00011000, 0b11011011, 0b11111111, 0b11011011, 0b00011000, }; +#define imgGPS_width 8 +#define imgGPS_height 8 +const unsigned char imgGPS[] PROGMEM = {0x00, 0x07, 0x39, 0x2D, 0xFF, 0x48, 0x48, 0x60}; const uint8_t imgUSB[] PROGMEM = {0x00, 0xfc, 0xf0, 0xfc, 0x88, 0xff, 0x86, 0xfe, 0x85, 0xfe, 0x89, 0xff, 0xf1, 0xfc, 0x00, 0xfc}; const uint8_t imgUSB_HighResolution[] PROGMEM = {0x00, 0x3e, 0xf8, 0x80, 0x43, 0xf8, 0xc0, 0xc2, 0xff, 0x60, 0x42, 0xfc, diff --git a/src/graphics/niche/InkHUD/Applet.cpp b/src/graphics/niche/InkHUD/Applet.cpp index d2fdc41f9..50efff8ff 100644 --- a/src/graphics/niche/InkHUD/Applet.cpp +++ b/src/graphics/niche/InkHUD/Applet.cpp @@ -649,30 +649,6 @@ std::string InkHUD::Applet::getTimeString() return getTimeString(getValidTime(RTCQuality::RTCQualityDevice, true)); } -// Calculate how many nodes have been seen within our preferred window of activity -// This period is set by user, via the menu -// Todo: optimize to calculate once only per WindowManager::render -uint16_t InkHUD::Applet::getActiveNodeCount() -{ - // Don't even try to count nodes if RTC isn't set - // The last heard values in nodedb will be incomprehensible - if (getRTCQuality() == RTCQualityNone) - return 0; - - uint16_t count = 0; - - // For each node in db - for (uint16_t i = 0; i < nodeDB->getNumMeshNodes(); i++) { - const meshtastic_NodeInfoLite *node = nodeDB->getMeshNodeByIndex(i); - - // Check if heard recently, and not our own node - if (sinceLastSeen(node) < settings->recentlyActiveSeconds && node->num != nodeDB->getNodeNum()) - count++; - } - - return count; -} - // Get an abbreviated, human readable, distance string // Honors config.display.units, to offer both metric and imperial std::string InkHUD::Applet::localizeDistance(uint32_t meters) diff --git a/src/graphics/niche/InkHUD/Applet.h b/src/graphics/niche/InkHUD/Applet.h index 2aa5bc640..84891b1fe 100644 --- a/src/graphics/niche/InkHUD/Applet.h +++ b/src/graphics/niche/InkHUD/Applet.h @@ -183,7 +183,6 @@ class Applet : public GFX SignalStrength getSignalStrength(float snr, float rssi); // Interpret SNR and RSSI, as an easy to understand value std::string getTimeString(uint32_t epochSeconds); // Human readable std::string getTimeString(); // Current time, human readable - uint16_t getActiveNodeCount(); // Duration determined by user, in onscreen menu std::string localizeDistance(uint32_t meters); // Human readable distance, imperial or metric std::string parse(const std::string &text); // Handle text which might contain special chars std::string parseShortName(meshtastic_NodeInfoLite *node); // Get the shortname, or a substitute if has unprintable chars diff --git a/src/graphics/niche/InkHUD/Applets/System/Menu/MenuApplet.cpp b/src/graphics/niche/InkHUD/Applets/System/Menu/MenuApplet.cpp index ac1fd1e73..5e8a08e75 100644 --- a/src/graphics/niche/InkHUD/Applets/System/Menu/MenuApplet.cpp +++ b/src/graphics/niche/InkHUD/Applets/System/Menu/MenuApplet.cpp @@ -2614,6 +2614,8 @@ uint16_t InkHUD::MenuApplet::getSystemInfoPanelHeight() void InkHUD::MenuApplet::sendText(NodeNum dest, ChannelIndex channel, const char *message) { meshtastic_MeshPacket *p = router->allocForSending(); + if (!p) + return; p->decoded.portnum = meshtastic_PortNum_TEXT_MESSAGE_APP; p->to = dest; p->channel = channel; @@ -2622,7 +2624,7 @@ void InkHUD::MenuApplet::sendText(NodeNum dest, ChannelIndex channel, const char memcpy(p->decoded.payload.bytes, message, p->decoded.payload.size); // Tack on a bell character if requested - if (moduleConfig.canned_message.send_bell && p->decoded.payload.size < meshtastic_Constants_DATA_PAYLOAD_LEN) { + if (moduleConfig.canned_message.send_bell && p->decoded.payload.size + 1 < meshtastic_Constants_DATA_PAYLOAD_LEN) { p->decoded.payload.bytes[p->decoded.payload.size] = 7; // Bell character p->decoded.payload.bytes[p->decoded.payload.size + 1] = '\0'; // Append Null Terminator p->decoded.payload.size++; diff --git a/src/graphics/niche/InkHUD/Applets/System/Notification/NotificationApplet.cpp b/src/graphics/niche/InkHUD/Applets/System/Notification/NotificationApplet.cpp index 682c4de5e..e2090269e 100644 --- a/src/graphics/niche/InkHUD/Applets/System/Notification/NotificationApplet.cpp +++ b/src/graphics/niche/InkHUD/Applets/System/Notification/NotificationApplet.cpp @@ -234,7 +234,8 @@ std::string InkHUD::NotificationApplet::getNotificationText(uint16_t widthAvaila // Pick source of message const StoredMessage *message = msgIsBroadcast ? &inkhud->persistence->latestMessage.broadcast : &inkhud->persistence->latestMessage.dm; - + if (!message->sender || !messageStore.isMessageVisible(*message)) + return parse(text); // Find info about the sender meshtastic_NodeInfoLite *node = nodeDB->getMeshNode(message->sender); @@ -270,4 +271,4 @@ std::string InkHUD::NotificationApplet::getNotificationText(uint16_t widthAvaila return parse(text); } -#endif \ No newline at end of file +#endif diff --git a/src/graphics/niche/InkHUD/Applets/User/AllMessage/AllMessageApplet.cpp b/src/graphics/niche/InkHUD/Applets/User/AllMessage/AllMessageApplet.cpp index ec266771e..740af32a7 100644 --- a/src/graphics/niche/InkHUD/Applets/User/AllMessage/AllMessageApplet.cpp +++ b/src/graphics/niche/InkHUD/Applets/User/AllMessage/AllMessageApplet.cpp @@ -46,7 +46,7 @@ void InkHUD::AllMessageApplet::onRender(bool full) message = &latestMessage->dm; // Short circuit: no text message - if (!message->sender) { + if (!message->sender || !messageStore.isMessageVisible(*message)) { printAt(X(0.5), Y(0.5), "No Message", CENTER, MIDDLE); return; } @@ -138,4 +138,4 @@ bool InkHUD::AllMessageApplet::approveNotification(Notification &n) return true; } -#endif \ No newline at end of file +#endif diff --git a/src/graphics/niche/InkHUD/Applets/User/DM/DMApplet.cpp b/src/graphics/niche/InkHUD/Applets/User/DM/DMApplet.cpp index 4940e69bf..9f1471c90 100644 --- a/src/graphics/niche/InkHUD/Applets/User/DM/DMApplet.cpp +++ b/src/graphics/niche/InkHUD/Applets/User/DM/DMApplet.cpp @@ -42,7 +42,7 @@ int InkHUD::DMApplet::onReceiveTextMessage(const meshtastic_MeshPacket *p) void InkHUD::DMApplet::onRender(bool full) { // Abort if no text message - if (!latestMessage->dm.sender) { + if (!latestMessage->dm.sender || !messageStore.isMessageVisible(latestMessage->dm)) { printAt(X(0.5), Y(0.5), "No DMs", CENTER, MIDDLE); return; } @@ -131,4 +131,4 @@ bool InkHUD::DMApplet::approveNotification(Notification &n) return true; } -#endif \ No newline at end of file +#endif diff --git a/src/graphics/niche/InkHUD/Applets/User/ThreadedMessage/ThreadedMessageApplet.cpp b/src/graphics/niche/InkHUD/Applets/User/ThreadedMessage/ThreadedMessageApplet.cpp index 31aeaa814..a0e9e201a 100644 --- a/src/graphics/niche/InkHUD/Applets/User/ThreadedMessage/ThreadedMessageApplet.cpp +++ b/src/graphics/niche/InkHUD/Applets/User/ThreadedMessage/ThreadedMessageApplet.cpp @@ -191,7 +191,8 @@ ProcessMessage InkHUD::ThreadedMessageApplet::handleReceived(const meshtastic_Me return ProcessMessage::CONTINUE; // Store in the global messageStore - this handles sender, timestamp, channel, text, and ack status - messageStore.addFromPacket(mp); + if (!messageStore.tryAddFromPacket(mp)) + return ProcessMessage::CONTINUE; // If this was an incoming message, suggest that our applet becomes foreground, if permitted if (getFrom(&mp) != nodeDB->getNodeNum()) @@ -216,13 +217,6 @@ bool InkHUD::ThreadedMessageApplet::approveNotification(Notification &n) return true; } -// Save messages to flash via the global messageStore. -// The global store holds messages for all channels; no per-channel file is needed. -void InkHUD::ThreadedMessageApplet::saveMessagesToFlash() -{ - messageStore.saveToFlash(); -} - // Messages are loaded once by InkHUD::begin() before applets start. // Nothing to do here at per-applet activation time. void InkHUD::ThreadedMessageApplet::loadMessagesFromFlash() diff --git a/src/graphics/niche/InkHUD/Applets/User/ThreadedMessage/ThreadedMessageApplet.h b/src/graphics/niche/InkHUD/Applets/User/ThreadedMessage/ThreadedMessageApplet.h index 8ec7d48a5..df1a345d2 100644 --- a/src/graphics/niche/InkHUD/Applets/User/ThreadedMessage/ThreadedMessageApplet.h +++ b/src/graphics/niche/InkHUD/Applets/User/ThreadedMessage/ThreadedMessageApplet.h @@ -46,7 +46,6 @@ class ThreadedMessageApplet : public Applet, public SinglePortModule bool approveNotification(Notification &n) override; // Which notifications to suppress protected: - void saveMessagesToFlash(); void loadMessagesFromFlash(); uint8_t channelIndex = 0; diff --git a/src/graphics/niche/InkHUD/Events.cpp b/src/graphics/niche/InkHUD/Events.cpp index ddb4a57b7..1a5e438d9 100644 --- a/src/graphics/niche/InkHUD/Events.cpp +++ b/src/graphics/niche/InkHUD/Events.cpp @@ -534,13 +534,19 @@ int InkHUD::Events::onReceiveTextMessage(const meshtastic_MeshPacket *packet) if (getFrom(packet) == nodeDB->getNodeNum()) return 0; + if (!messageStore.shouldStorePacket(*packet)) + return 0; + bool isBroadcastMsg = isBroadcast(packet->to); inkhud->persistence->latestMessage.wasBroadcast = isBroadcastMsg; if (!isBroadcastMsg) { // DMs never pass through ThreadedMessageApplet, so add them to the global store here // so they survive reboots. Derive the latestMessage cache entry from the stored result. - inkhud->persistence->latestMessage.dm = messageStore.addFromPacket(*packet); + const StoredMessage *stored = messageStore.tryAddFromPacket(*packet); + if (!stored) + return 0; + inkhud->persistence->latestMessage.dm = *stored; } else { // Broadcasts are added to the global store by ThreadedMessageApplet::handleReceived(). // Here we only update the latestMessage cache used by AllMessageApplet / NotificationApplet. diff --git a/src/graphics/niche/InkHUD/InkHUD.cpp b/src/graphics/niche/InkHUD/InkHUD.cpp index 2cdb9f507..fe5a32855 100644 --- a/src/graphics/niche/InkHUD/InkHUD.cpp +++ b/src/graphics/niche/InkHUD/InkHUD.cpp @@ -326,44 +326,6 @@ void InkHUD::InkHUD::touchNavDown() } } -// Call this when touch input needs joystick-like left navigation independent of joystick-enabled mode -void InkHUD::InkHUD::touchNavLeft() -{ - switch ((persistence->settings.rotation + persistence->settings.joystick.alignment) % 4) { - case 1: // 90 deg - events->onTouchNavDown(); - break; - case 2: // 180 deg - events->onTouchNavRight(); - break; - case 3: // 270 deg - events->onTouchNavUp(); - break; - default: // 0 deg - events->onTouchNavLeft(); - break; - } -} - -// Call this when touch input needs joystick-like right navigation independent of joystick-enabled mode -void InkHUD::InkHUD::touchNavRight() -{ - switch ((persistence->settings.rotation + persistence->settings.joystick.alignment) % 4) { - case 1: // 90 deg - events->onTouchNavUp(); - break; - case 2: // 180 deg - events->onTouchNavLeft(); - break; - case 3: // 270 deg - events->onTouchNavDown(); - break; - default: // 0 deg - events->onTouchNavRight(); - break; - } -} - void InkHUD::InkHUD::touchTap(uint16_t x, uint16_t y) { events->onTouchTap(x, y, false); diff --git a/src/graphics/niche/InkHUD/InkHUD.h b/src/graphics/niche/InkHUD/InkHUD.h index 0c1682637..538896792 100644 --- a/src/graphics/niche/InkHUD/InkHUD.h +++ b/src/graphics/niche/InkHUD/InkHUD.h @@ -71,8 +71,6 @@ class InkHUD void navRight(); void touchNavUp(); void touchNavDown(); - void touchNavLeft(); - void touchNavRight(); void touchTap(uint16_t x, uint16_t y); void touchLongPress(uint16_t x, uint16_t y); diff --git a/src/graphics/niche/InkHUD/Persistence.cpp b/src/graphics/niche/InkHUD/Persistence.cpp index 8a8140bb3..f588060b4 100644 --- a/src/graphics/niche/InkHUD/Persistence.cpp +++ b/src/graphics/niche/InkHUD/Persistence.cpp @@ -26,6 +26,10 @@ void InkHUD::Persistence::loadLatestMessage() int lastBroadcastPos = -1, lastDMPos = -1, pos = 0; for (const StoredMessage &m : messageStore.getLiveMessages()) { + if (!messageStore.isMessageVisible(m)) { + pos++; + continue; + } if (m.type == MessageType::BROADCAST) { latestMessage.broadcast = m; lastBroadcastPos = pos; diff --git a/src/graphics/niche/InkHUD/docs/README.md b/src/graphics/niche/InkHUD/docs/README.md index bea373b7a..7ff81c007 100644 --- a/src/graphics/niche/InkHUD/docs/README.md +++ b/src/graphics/niche/InkHUD/docs/README.md @@ -496,8 +496,10 @@ We keep this separate latest-message cache for this purpose, because: Broadcasts and DMs take different paths into `messageStore`: -- **Broadcasts** - `ThreadedMessageApplet::handleReceived()` calls `messageStore.addFromPacket()`. `Events::onReceiveTextMessage()` then updates `latestMessage.broadcast` separately for fast access by `AllMessageApplet` and `NotificationApplet`. -- **DMs** - `ThreadedMessageApplet` skips DMs entirely. `Events::onReceiveTextMessage()` calls `messageStore.addFromPacket()` directly and stores the result in `latestMessage.dm`. +- **Broadcasts** - `ThreadedMessageApplet::handleReceived()` calls `messageStore.tryAddFromPacket()`. `Events::onReceiveTextMessage()` then updates `latestMessage.broadcast` separately for fast access by `AllMessageApplet` and `NotificationApplet`. +- **DMs** - `ThreadedMessageApplet` skips DMs entirely. `Events::onReceiveTextMessage()` calls `messageStore.tryAddFromPacket()` directly and stores the result in `latestMessage.dm`. + +`tryAddFromPacket()` returns `nullptr` when the packet is filtered out (see `shouldStorePacket()`), so both paths must null-check before use. #### Saving / Loading diff --git a/src/graphics/niche/Inputs/TwoButton.cpp b/src/graphics/niche/Inputs/TwoButton.cpp index 1a27e039b..4afe8d5b9 100644 --- a/src/graphics/niche/Inputs/TwoButton.cpp +++ b/src/graphics/niche/Inputs/TwoButton.cpp @@ -117,14 +117,6 @@ void TwoButton::setHandlerDown(uint8_t whichButton, Callback onDown) buttons[whichButton].onDown = onDown; } -// Set what should happen when a button becomes unpressed -// Use this to implement a "While held" behavior -void TwoButton::setHandlerUp(uint8_t whichButton, Callback onUp) -{ - assert(whichButton < 2); - buttons[whichButton].onUp = onUp; -} - // Set what should happen when a "short press" event has occurred void TwoButton::setHandlerShortPress(uint8_t whichButton, Callback onShortPress) { diff --git a/src/graphics/niche/Inputs/TwoButton.h b/src/graphics/niche/Inputs/TwoButton.h index ae66adf96..fc2805ae5 100644 --- a/src/graphics/niche/Inputs/TwoButton.h +++ b/src/graphics/niche/Inputs/TwoButton.h @@ -38,7 +38,6 @@ class TwoButton : protected concurrency::OSThread void setWiring(uint8_t whichButton, uint8_t pin, bool internalPullup = false); void setTiming(uint8_t whichButton, uint32_t debounceMs, uint32_t longpressMs); void setHandlerDown(uint8_t whichButton, Callback onDown); - void setHandlerUp(uint8_t whichButton, Callback onUp); void setHandlerShortPress(uint8_t whichButton, Callback onShortPress); void setHandlerLongPress(uint8_t whichButton, Callback onLongPress); diff --git a/src/graphics/niche/Inputs/TwoButtonExtended.cpp b/src/graphics/niche/Inputs/TwoButtonExtended.cpp index f979faca9..bb5904cb6 100644 --- a/src/graphics/niche/Inputs/TwoButtonExtended.cpp +++ b/src/graphics/niche/Inputs/TwoButtonExtended.cpp @@ -194,14 +194,6 @@ void TwoButtonExtended::setHandlerDown(uint8_t whichButton, Callback onDown) buttons[whichButton].onDown = onDown; } -// Set what should happen when a button becomes unpressed -// Use this to implement a "While held" behavior -void TwoButtonExtended::setHandlerUp(uint8_t whichButton, Callback onUp) -{ - assert(whichButton < 2); - buttons[whichButton].onUp = onUp; -} - // Set what should happen when a "short press" event has occurred void TwoButtonExtended::setHandlerShortPress(uint8_t whichButton, Callback onPress) { @@ -217,26 +209,6 @@ void TwoButtonExtended::setHandlerLongPress(uint8_t whichButton, Callback onLong buttons[whichButton].onLongPress = onLongPress; } -// Set what should happen when a joystick button becomes pressed -// Use this to implement a "while held" behavior -void TwoButtonExtended::setJoystickDownHandlers(Callback uDown, Callback dDown, Callback lDown, Callback rDown) -{ - joystick[Direction::UP].onDown = uDown; - joystick[Direction::DOWN].onDown = dDown; - joystick[Direction::LEFT].onDown = lDown; - joystick[Direction::RIGHT].onDown = rDown; -} - -// Set what should happen when a joystick button becomes unpressed -// Use this to implement a "while held" behavior -void TwoButtonExtended::setJoystickUpHandlers(Callback uUp, Callback dUp, Callback lUp, Callback rUp) -{ - joystick[Direction::UP].onUp = uUp; - joystick[Direction::DOWN].onUp = dUp; - joystick[Direction::LEFT].onUp = lUp; - joystick[Direction::RIGHT].onUp = rUp; -} - // Set what should happen when a "press" event has fired // Note: this will occur while the joystick button is still held void TwoButtonExtended::setJoystickPressHandlers(Callback uPress, Callback dPress, Callback lPress, Callback rPress) diff --git a/src/graphics/niche/Inputs/TwoButtonExtended.h b/src/graphics/niche/Inputs/TwoButtonExtended.h index eb536907d..f6b52b923 100644 --- a/src/graphics/niche/Inputs/TwoButtonExtended.h +++ b/src/graphics/niche/Inputs/TwoButtonExtended.h @@ -49,11 +49,8 @@ class TwoButtonExtended : protected concurrency::OSThread void setTiming(uint8_t whichButton, uint32_t debounceMs, uint32_t longpressMs); void setJoystickDebounce(uint32_t debounceMs); void setHandlerDown(uint8_t whichButton, Callback onDown); - void setHandlerUp(uint8_t whichButton, Callback onUp); void setHandlerShortPress(uint8_t whichButton, Callback onShortPress); void setHandlerLongPress(uint8_t whichButton, Callback onLongPress); - void setJoystickDownHandlers(Callback uDown, Callback dDown, Callback ldown, Callback rDown); - void setJoystickUpHandlers(Callback uUp, Callback dUp, Callback lUp, Callback rUp); void setJoystickPressHandlers(Callback uPress, Callback dPress, Callback lPress, Callback rPress); void setTwoWayRockerPressHandlers(Callback lPress, Callback rPress); diff --git a/src/main.cpp b/src/main.cpp index 47d51dea3..c737a902f 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -212,7 +212,7 @@ using namespace concurrency; volatile static const char slipstreamTZString[] = {USERPREFS_TZ_STRING}; // We always create a screen object, but we only init it if we find the hardware -graphics::Screen *screen = nullptr; +std::unique_ptr screen = nullptr; // Global power status meshtastic::PowerStatus *powerStatus = new meshtastic::PowerStatus(); @@ -428,10 +428,14 @@ void setup() #if ARCH_PORTDUINO RTCQuality ourQuality = RTCQualityDevice; +#ifdef __linux__ + // timedatectl is systemd-only, so macOS, Windows and WASM stay at + // RTCQualityDevice rather than claim NTP quality we have not verified. std::string timeCommandResult = exec("timedatectl status | grep synchronized | grep yes -c"); if (timeCommandResult[0] == '1') { ourQuality = RTCQualityNTP; } +#endif struct timeval tv; tv.tv_sec = time(NULL); @@ -962,15 +966,15 @@ void setup() if (config.display.displaymode != meshtastic_Config_DisplayConfig_DisplayMode_COLOR) { #if defined(HAS_SPI_TFT) || defined(USE_EINK) || defined(USE_SPISSD1306) - screen = new graphics::Screen(screen_found, screen_model, screen_geometry); + screen = std::make_unique(screen_found, screen_model, screen_geometry); #elif defined(ARCH_PORTDUINO) if ((screen_found.port != ScanI2C::I2CPort::NO_I2C || portduino_config.displayPanel) && config.display.displaymode != meshtastic_Config_DisplayConfig_DisplayMode_COLOR) { - screen = new graphics::Screen(screen_found, screen_model, screen_geometry); + screen = std::make_unique(screen_found, screen_model, screen_geometry); } #else if (screen_found.port != ScanI2C::I2CPort::NO_I2C) - screen = new graphics::Screen(screen_found, screen_model, screen_geometry); + screen = std::make_unique(screen_found, screen_model, screen_geometry); #endif } #endif // HAS_SCREEN @@ -1074,6 +1078,7 @@ void setup() nodeDB->hasWarned = true; } #endif + nodeDB->notifyPendingLicensedIdentityMigration(); #if !MESHTASTIC_EXCLUDE_INPUTBROKER if (inputBroker) inputBroker->Init(); @@ -1107,6 +1112,23 @@ void setup() #endif #endif +#if defined(SENSECAP_INDICATOR) + // The ST7701 panel shares SCK/MOSI/MISO (41/48/47) with the SX1262, and its host is SPI2_HOST, + // which on the S3 is the same peripheral as the Arduino `SPI` object (FSPI == SPI2). + // LovyanGFX bit-bangs the ST7701 init sequence on those pins, and because this variant builds + // with USE_ARDUINO_HAL_GPIO it does so via Arduino pinMode()/digitalWrite(). pinMode() calls + // perimanSetPinBus(.., ESP32_BUS_TYPE_GPIO, ..), whose deinit callback (spiDetachBus_SCK) ends + // up in spiStopBus() and gates the SPI2 clock. RadioLib then spins forever in spiTransferByte() + // waiting on cmd.update, which never clears on a stopped peripheral, and the watchdog fires. + // + // Restart the bus here, after the panel is up and before the radio is touched. Note that + // SPIClass::begin() early-returns when _spi is already non-NULL, so end() first is required. + SPI.end(); + SPI.begin(LORA_SCK, LORA_MISO, LORA_MOSI, -1); // CS is an IO-expander pin, driven by RadioLib + SPI.setFrequency(4000000); + LOG_DEBUG("SPI2 restarted after ST7701 init (SCK=%d, MISO=%d, MOSI=%d)", LORA_SCK, LORA_MISO, LORA_MOSI); +#endif + auto rIf = initLoRa(); lateInitVariant(); // Do board specific init (see extra_variants/README.md for documentation) @@ -1218,7 +1240,7 @@ bool runASAP; // TODO find better home than main.cpp extern meshtastic_DeviceMetadata getDeviceMetadata() { - meshtastic_DeviceMetadata deviceMetadata; + meshtastic_DeviceMetadata deviceMetadata = meshtastic_DeviceMetadata_init_default; strncpy(deviceMetadata.firmware_version, optstr(APP_VERSION), sizeof(deviceMetadata.firmware_version)); deviceMetadata.device_state_version = DEVICESTATE_CUR_VER; deviceMetadata.canShutdown = pmu_found || HAS_CPU_SHUTDOWN; @@ -1258,6 +1280,8 @@ extern meshtastic_DeviceMetadata getDeviceMetadata() #if !defined(HAS_RGB_LED) && !RAK_4631 deviceMetadata.excluded_modules |= meshtastic_ExcludedModules_AMBIENTLIGHTING_CONFIG; #endif + // Range test is always excluded as of 2.8 + deviceMetadata.excluded_modules |= meshtastic_ExcludedModules_RANGETEST_CONFIG; // No bluetooth on these targets (yet): // Pico W / 2W may get it at some point @@ -1274,6 +1298,9 @@ extern meshtastic_DeviceMetadata getDeviceMetadata() #if !(MESHTASTIC_EXCLUDE_PKI) deviceMetadata.hasPKC = true; +#endif +#if !(MESHTASTIC_EXCLUDE_PKI) && !(MESHTASTIC_EXCLUDE_XEDDSA) + deviceMetadata.has_xeddsa = true; #endif return deviceMetadata; } diff --git a/src/main.h b/src/main.h index 98dcccc71..19b1bace0 100644 --- a/src/main.h +++ b/src/main.h @@ -11,6 +11,7 @@ #include "mesh/generated/meshtastic/telemetry.pb.h" #include #include +#include #if defined(ARCH_ESP32) && !defined(CONFIG_IDF_TARGET_ESP32S2) && !MESHTASTIC_EXCLUDE_BLUETOOTH #include "nimble/NimbleBluetooth.h" extern NimbleBluetooth *nimbleBluetooth; @@ -68,7 +69,7 @@ extern UdpMulticastHandler *udpHandler; #endif // Global Screen singleton. -extern graphics::Screen *screen; +extern std::unique_ptr screen; #if !defined(ARCH_STM32WL) && !MESHTASTIC_EXCLUDE_I2C && !MESHTASTIC_EXCLUDE_ACCELEROMETER #include "motion/AccelerometerThread.h" diff --git a/src/memGet.cpp b/src/memGet.cpp index cbba0f4e4..2da93bf12 100644 --- a/src/memGet.cpp +++ b/src/memGet.cpp @@ -9,7 +9,6 @@ */ #include "memGet.h" #include "configuration.h" -#include "memory/MemAudit.h" #if defined(MESHTASTIC_DYNAMIC_SBRK_HEAP) #include @@ -108,16 +107,3 @@ uint32_t MemGet::getPsramSize() return 0; #endif } - -void displayPercentHeapFree() -{ - uint32_t freeHeap = memGet.getFreeHeap(); - uint32_t totalHeap = memGet.getHeapSize(); - if (totalHeap == 0 || totalHeap == UINT32_MAX) { - LOG_INFO("Heap size unavailable"); - return; - } - int percent = (int)((freeHeap * 100) / totalHeap); - LOG_INFO("Heap free: %d%% (%u/%u bytes)", percent, freeHeap, totalHeap); - memaudit::logBreakdown("heap"); // per-subsystem breakdown rides along with the periodic heap log -} \ No newline at end of file diff --git a/src/mesh/Channels.cpp b/src/mesh/Channels.cpp index f1d97ebb4..2d8d4f246 100644 --- a/src/mesh/Channels.cpp +++ b/src/mesh/Channels.cpp @@ -128,11 +128,13 @@ bool Channels::ensureLicensedOperation() } auto &channelSettings = channel.settings; if (strcasecmp(channelSettings.name, Channels::adminChannel) == 0) { - channel.role = meshtastic_Channel_Role_DISABLED; - channelSettings.psk.bytes[0] = 0; - channelSettings.psk.size = 0; - hasEncryptionOrAdmin = true; - channels.setChannel(channel); + if (channel.role != meshtastic_Channel_Role_DISABLED || channelSettings.psk.size > 0) { + channel.role = meshtastic_Channel_Role_DISABLED; + channelSettings.psk.bytes[0] = 0; + channelSettings.psk.size = 0; + hasEncryptionOrAdmin = true; + channels.setChannel(channel); + } } else if (channelSettings.psk.size > 0) { channelSettings.psk.bytes[0] = 0; @@ -563,4 +565,4 @@ bool Channels::setDefaultPresetCryptoForHash(ChannelHash channelHash) int16_t Channels::setActiveByIndex(ChannelIndex channelIndex) { return setCrypto(channelIndex); -} \ No newline at end of file +} diff --git a/src/mesh/FloodingRouter.cpp b/src/mesh/FloodingRouter.cpp index 13f98299f..7048cd918 100644 --- a/src/mesh/FloodingRouter.cpp +++ b/src/mesh/FloodingRouter.cpp @@ -51,8 +51,8 @@ bool FloodingRouter::shouldFilterReceived(const meshtastic_MeshPacket *p) LOG_DEBUG("Repeated reliable tx"); // Check if it's still in the Tx queue, if not, we have to relay it again if (!findInTxQueue(p->from, p->id)) { - reprocessPacket(p); - perhapsRebroadcast(p); + if (reprocessPacket(p)) + perhapsRebroadcast(p); } } else { perhapsCancelDupe(p); @@ -68,6 +68,12 @@ bool FloodingRouter::perhapsHandleUpgradedPacket(const meshtastic_MeshPacket *p) { // isRebroadcaster() is duplicated in perhapsRebroadcast(), but this avoids confusing log messages if (isRebroadcaster() && iface && p->hop_limit > 0) { + // Verify the replacement before deleting the valid lower-hop copy waiting in the TX queue. + // This is intentionally redundant with ReliableRouter's ingress gate: it keeps this helper + // safe if another caller is introduced later. + if (passesRoutingAuthGate(const_cast(p)) != RoutingAuthVerdict::ACCEPT) + return true; + // If we overhear a duplicate copy of the packet with more hops left than the one we are waiting to // rebroadcast, then remove the packet currently sitting in the TX queue and use this one instead. uint8_t dropThreshold = p->hop_limit; // remove queued packets that have fewer hops remaining @@ -75,7 +81,8 @@ bool FloodingRouter::perhapsHandleUpgradedPacket(const meshtastic_MeshPacket *p) LOG_DEBUG("Processing upgraded packet 0x%08x for rebroadcast with hop limit %d (dropping queued < %d)", p->id, p->hop_limit, dropThreshold); - reprocessPacket(p); + if (!reprocessPacket(p)) + return true; perhapsRebroadcast(p); rxDupe++; @@ -87,32 +94,24 @@ bool FloodingRouter::perhapsHandleUpgradedPacket(const meshtastic_MeshPacket *p) return false; } -void FloodingRouter::reprocessPacket(const meshtastic_MeshPacket *p) +bool FloodingRouter::reprocessPacket(const meshtastic_MeshPacket *p) { + if (p->which_payload_variant != meshtastic_MeshPacket_decoded_tag) { + auto decodedState = perhapsDecode(const_cast(p)); + if (decodedState != DecodeState::DECODE_SUCCESS && decodedState != DecodeState::DECODE_OPAQUE) + return false; + } + if (nodeDB) nodeDB->updateFrom(*p); #if !MESHTASTIC_EXCLUDE_TRACEROUTE - if (traceRouteModule && p->which_payload_variant != meshtastic_MeshPacket_decoded_tag) { - // If we got a packet that is not decoded, try to decode it so we can check for traceroute. - auto decodedState = perhapsDecode(const_cast(p)); - if (decodedState == DecodeState::DECODE_SUCCESS) { - // parsing was successful, print for debugging - printPacket("reprocessPacket(DUP)", p); - } else { - // Fatal decoding error, we can't do anything with this packet - LOG_WARN( - "FloodingRouter::reprocessPacket: Fatal decode error (state=%d, id=0x%08x, from=%u), can't check for traceroute", - static_cast(decodedState), p->id, getFrom(p)); - return; - } - } - if (traceRouteModule && p->which_payload_variant == meshtastic_MeshPacket_decoded_tag && p->decoded.portnum == meshtastic_PortNum_TRACEROUTE_APP) { traceRouteModule->processUpgradedPacket(*p); } #endif + return true; } bool FloodingRouter::roleAllowsCancelingDupe(const meshtastic_MeshPacket *p) diff --git a/src/mesh/FloodingRouter.h b/src/mesh/FloodingRouter.h index e8a2e9685..6bd5fca41 100644 --- a/src/mesh/FloodingRouter.h +++ b/src/mesh/FloodingRouter.h @@ -64,7 +64,7 @@ class FloodingRouter : public Router bool perhapsHandleUpgradedPacket(const meshtastic_MeshPacket *p); /* Call when we receive a packet that needs some reprocessing, but afterwards should be filtered */ - void reprocessPacket(const meshtastic_MeshPacket *p); + bool reprocessPacket(const meshtastic_MeshPacket *p); // Return false for roles like ROUTER which should always rebroadcast even when we've heard another rebroadcast of // the same packet @@ -75,4 +75,4 @@ class FloodingRouter : public Router // Return true if we are a rebroadcaster bool isRebroadcaster(); -}; \ No newline at end of file +}; diff --git a/src/mesh/HardwareRNG.cpp b/src/mesh/HardwareRNG.cpp index 940453053..43a3e0385 100644 --- a/src/mesh/HardwareRNG.cpp +++ b/src/mesh/HardwareRNG.cpp @@ -22,6 +22,12 @@ extern Adafruit_nRFCrypto nRFCrypto; #include #ifdef __linux__ #include // getrandom() +#elif defined(_WIN32) +// Order is load-bearing, hence the blank line: bcrypt.h uses LONG/ULONG from +// windows.h and does not include it itself. +#include + +#include // BCryptGenRandom() #else #include // arc4random_buf() on Darwin/BSD #endif @@ -128,6 +134,12 @@ bool fill(uint8_t *buffer, size_t length, bool useRadioEntropy) if (generated == static_cast(length)) { filled = true; } +#elif defined(_WIN32) + // No getrandom/arc4random on Windows; BCryptGenRandom is the documented CSPRNG. + // Preferred over std::random_device, whose libstdc++ Windows backend reports entropy() == 0. + if (BCryptGenRandom(NULL, buffer, static_cast(length), BCRYPT_USE_SYSTEM_PREFERRED_RNG) == 0) { // STATUS_SUCCESS + filled = true; + } #elif defined(__EMSCRIPTEN__) // Browser/wasm: no getrandom/arc4random - fall through to std::random_device, // which emscripten backs with crypto.getRandomValues(). diff --git a/src/mesh/MemoryPool.h b/src/mesh/MemoryPool.h index d10c8cf96..ed20fb334 100644 --- a/src/mesh/MemoryPool.h +++ b/src/mesh/MemoryPool.h @@ -114,7 +114,10 @@ template class MemoryDynamic : public Allocator virtual T *alloc(TickType_t maxWait) override { T *p = (T *)malloc(sizeof(T)); - assert(p); + if (!p) { + LOG_WARN("malloc(%u) failed, heap exhausted!", (unsigned)sizeof(T)); + return nullptr; + } this->auditAdd((int32_t)sizeof(T)); return p; } diff --git a/src/mesh/MeshModule.cpp b/src/mesh/MeshModule.cpp index a4acea7f1..5dc7fba4a 100644 --- a/src/mesh/MeshModule.cpp +++ b/src/mesh/MeshModule.cpp @@ -18,7 +18,7 @@ uint8_t MeshModule::numPeriodicModules = 0; */ meshtastic_MeshPacket *MeshModule::currentReply; -MeshModule::MeshModule(const char *_name) : name(_name) +MeshModule::MeshModule(const char *_name, meshtastic_PortNum _ourPortNum) : name(_name), ourPortNum(_ourPortNum) { // Can't trust static initializer order, so we check each time if (!modules) @@ -27,6 +27,12 @@ MeshModule::MeshModule(const char *_name) : name(_name) modules->push_back(this); } +bool MeshModule::replyPortMatches(meshtastic_PortNum modulePort, const meshtastic_MeshPacket &mp) +{ + return modulePort != meshtastic_PortNum_UNKNOWN_APP && mp.which_payload_variant == meshtastic_MeshPacket_decoded_tag && + mp.decoded.portnum == modulePort; +} + void MeshModule::setup() {} MeshModule::~MeshModule() @@ -57,6 +63,8 @@ meshtastic_MeshPacket *MeshModule::allocAckNak(meshtastic_Routing_Error err, Nod // So we manually call pb_encode_to_bytes and specify routing port number // auto p = allocDataProtobuf(c); meshtastic_MeshPacket *p = router->allocForSending(); + if (!p) + return nullptr; p->decoded.portnum = meshtastic_PortNum_ROUTING_APP; p->decoded.payload.size = pb_encode_to_bytes(p->decoded.payload.bytes, sizeof(p->decoded.payload.bytes), &meshtastic_Routing_msg, &c); @@ -105,6 +113,7 @@ void MeshModule::callModules(meshtastic_MeshPacket &mp, RxSource src) auto &pi = **i; pi.currentRequest = ∓ + pi.ignoreRequest = false; /// We only call modules that are interested in the packet (and the message is destined to us or we are promiscious) bool wantsPacket = (isDecoded || pi.encryptedOk) && (pi.isPromiscuous || toUs) && pi.wantPacket(&mp); @@ -155,9 +164,13 @@ void MeshModule::callModules(meshtastic_MeshPacket &mp, RxSource src) // better solution (FIXME) would be to let phones have their own distinct addresses and we 'route' to them like // any other node. if (isDecoded && mp.decoded.want_response && toUs && (!isFromUs(&mp) || isToUs(&mp)) && !currentReply) { - pi.sendResponse(mp); + if (replyPortMatches(pi.ourPortNum, mp)) { + pi.sendResponse(mp); + LOG_INFO("Asked module '%s' to send a response", pi.name); + } else { + LOG_DEBUG("Module '%s' cannot respond on portnum=%d", pi.name, mp.decoded.portnum); + } ignoreRequest = ignoreRequest || pi.ignoreRequest; // If at least one module asks it, we may ignore a request - LOG_INFO("Asked module '%s' to send a response", pi.name); } else { LOG_DEBUG("Module '%s' considered", pi.name); } @@ -311,4 +324,4 @@ bool MeshModule::isRequestingFocus() } else return false; } -#endif \ No newline at end of file +#endif diff --git a/src/mesh/MeshModule.h b/src/mesh/MeshModule.h index 9d579d4f1..3dc6414a5 100644 --- a/src/mesh/MeshModule.h +++ b/src/mesh/MeshModule.h @@ -68,10 +68,12 @@ class MeshModule /** Constructor * name is for debugging output */ - MeshModule(const char *_name); + MeshModule(const char *_name, meshtastic_PortNum _ourPortNum = meshtastic_PortNum_UNKNOWN_APP); virtual ~MeshModule(); + static bool replyPortMatches(meshtastic_PortNum modulePort, const meshtastic_MeshPacket &mp); + /** For use only by MeshService */ static void callModules(meshtastic_MeshPacket &mp, RxSource src = RX_SRC_RADIO); @@ -88,6 +90,7 @@ class MeshModule #endif protected: const char *name; + meshtastic_PortNum ourPortNum; /** Most modules only care about packets that are destined for their node (i.e. broadcasts or has their node as the specific recipient) But some plugs might want to 'sniff' packets that are merely being routed (passing through the current node). Those @@ -103,7 +106,7 @@ class MeshModule * flag */ bool encryptedOk = false; - /* We allow modules to ignore a request without sending an error if they have a specific reason for it. */ + /* Per-packet flag cleared by callModules(); modules can suppress an error response for a specific request. */ bool ignoreRequest = false; /** diff --git a/src/mesh/MeshService.cpp b/src/mesh/MeshService.cpp index ce356d7cd..0efd23c81 100644 --- a/src/mesh/MeshService.cpp +++ b/src/mesh/MeshService.cpp @@ -137,12 +137,17 @@ void MeshService::loop() /// The radioConfig object just changed, call this to force the hw to change to the new settings void MeshService::reloadConfig(int saveWhat) { - // If we can successfully set this radio to these settings, save them to disk + // Only LoRa config and channels (freq/PSK/slot) affect the radio. Saves that only touch + // module config, device state, or the node database (e.g. favoriting a node) have no reason + // to re-init the LoRa chip - skip it there to avoid an unnecessary and risky SPI reconfigure. + if (saveWhat & (SEGMENT_CONFIG | SEGMENT_CHANNELS)) { + // If we can successfully set this radio to these settings, save them to disk - // This will also update the region as needed - nodeDB->resetRadioConfig(); // Don't let the phone send us fatally bad settings + // This will also update the region as needed + nodeDB->resetRadioConfig(); // Don't let the phone send us fatally bad settings - configChanged.notifyObservers(NULL); // This will cause radio hardware to change freqs etc + configChanged.notifyObservers(NULL); // This will cause radio hardware to change freqs etc + } nodeDB->saveToDisk(saveWhat); } @@ -257,8 +262,8 @@ void MeshService::handleToRadio(meshtastic_MeshPacket &p) p.to != NODENUM_BROADCAST && p.to != 0) // DM only { perhapsDecode(&p); - const StoredMessage &sm = messageStore.addFromPacket(p); - graphics::MessageRenderer::handleNewMessage(nullptr, sm, p); // notify UI + if (const StoredMessage *sm = messageStore.tryAddFromPacket(p)) + graphics::MessageRenderer::handleNewMessage(nullptr, *sm, p); // notify UI }) #if !MESHTASTIC_EXCLUDE_ADMIN // Note admin requests on their way out: AdminModule only accepts a response from a remote we @@ -319,13 +324,20 @@ void MeshService::sendToMesh(meshtastic_MeshPacket *p, RxSource src, bool ccToPh uint32_t mesh_packet_id = p->id; nodeDB->updateFrom(*p); // update our local DB for this packet (because phone might have sent position packets etc...) + // callModules' loopback gate keeps RX_SRC_LOCAL packets from RoutingModule, the only module + // that forwards to the phone, so deliver our own reply's copy here or the client never sees it. + const bool localDelivery = isToUs(p); + if (src == RX_SRC_LOCAL && localDelivery) + ccToPhone = true; + // Note: We might return !OK if our fifo was full, at that point the only option we have is to drop it ErrorCode res = router->sendLocal(p, src); /* NOTE(pboldin): Prepare and send QueueStatus message to the phone as a * high-priority message. */ meshtastic_QueueStatus qs = router->getQueueStatus(); - ErrorCode r = sendQueueStatusToPhone(qs, res, mesh_packet_id); + // SHOULD_RELEASE means "caller frees", not a send failure, so don't report it as one. + ErrorCode r = sendQueueStatusToPhone(qs, (res == ERRNO_SHOULD_RELEASE && localDelivery) ? ERRNO_OK : res, mesh_packet_id); if (r != ERRNO_OK) { LOG_DEBUG("Can't send status to phone"); } diff --git a/src/mesh/NextHopRouter.cpp b/src/mesh/NextHopRouter.cpp index f33de779c..f1ed831f8 100644 --- a/src/mesh/NextHopRouter.cpp +++ b/src/mesh/NextHopRouter.cpp @@ -11,6 +11,25 @@ NextHopRouter::NextHopRouter() {} +bool NextHopRouter::relayOpaquePacket(const meshtastic_MeshPacket *p) +{ + // Opaque traffic is never admitted to PacketHistory, NodeDB, modules, phone, MQTT, or ACK + // handling. Relay only from the immutable outer routing header and let hop exhaustion bound it. + const auto mode = config.device.rebroadcast_mode; + if (!iface || isToUs(p) || isFromUs(p) || p->id == 0 || p->hop_limit == 0 || !isRebroadcaster() || owner.is_licensed || + !IS_ONE_OF(mode, meshtastic_Config_DeviceConfig_RebroadcastMode_ALL, + meshtastic_Config_DeviceConfig_RebroadcastMode_ALL_SKIP_DECODING) || + (p->next_hop != NO_NEXT_HOP_PREFERENCE && p->next_hop != nodeDB->getLastByteOfNodeNum(getNodeNum()))) + return false; + + meshtastic_MeshPacket *relay = packetPool.allocCopy(*p); + if (!relay) + return false; + relay->hop_limit--; + relay->relay_node = nodeDB->getLastByteOfNodeNum(getNodeNum()); + return Router::send(relay) == ERRNO_OK; +} + PendingPacket::PendingPacket(meshtastic_MeshPacket *p, uint8_t numRetransmissions) { packet = p; @@ -65,16 +84,15 @@ bool NextHopRouter::shouldFilterReceived(const meshtastic_MeshPacket *p) LOG_INFO("Fallback to flooding from relay_node=0x%x", p->relay_node); // Check if it's still in the Tx queue, if not, we have to relay it again if (!findInTxQueue(p->from, p->id)) { - reprocessPacket(p); - perhapsRebroadcast(p); + if (reprocessPacket(p)) + perhapsRebroadcast(p); } } else { bool isRepeated = getHopsAway(*p) == 0; // If repeated and not in Tx queue anymore, try relaying again, or if we are the destination, send the ACK again if (isRepeated) { if (!findInTxQueue(p->from, p->id)) { - reprocessPacket(p); - if (!perhapsRebroadcast(p) && isToUs(p) && p->want_ack) { + if (reprocessPacket(p) && !perhapsRebroadcast(p) && isToUs(p) && p->want_ack) { sendAckNak(meshtastic_Routing_Error_NONE, getFrom(p), p->id, p->channel, 0); } } @@ -159,6 +177,9 @@ bool NextHopRouter::perhapsRebroadcast(const meshtastic_MeshPacket *p) } #endif + if (p->to == NODENUM_BROADCAST_NO_LORA) + return false; + // Allow rebroadcast if hop_limit > 0 OR if we're exhausting hops (which sets hop_limit = 0 but still needs one relay) if (!isToUs(p) && !isFromUs(p) && (p->hop_limit > 0 || exhaustHops)) { if (p->id != 0) { @@ -192,11 +213,10 @@ bool NextHopRouter::perhapsRebroadcast(const meshtastic_MeshPacket *p) } #endif - if (p->next_hop == NO_NEXT_HOP_PREFERENCE) { - FloodingRouter::send(tosend); - } else { - NextHopRouter::send(tosend); - } + ErrorCode res = + (p->next_hop == NO_NEXT_HOP_PREFERENCE) ? FloodingRouter::send(tosend) : NextHopRouter::send(tosend); + if (res == ERRNO_SHOULD_RELEASE) + packetPool.release(tosend); return true; } @@ -401,8 +421,10 @@ int32_t NextHopRouter::doRetransmissions() trafficManagementModule->clearNextHop(p.packet->to); } #endif - if (auto *copy = packetPool.allocCopy(*p.packet)) - FloodingRouter::send(copy); + if (auto *copy = packetPool.allocCopy(*p.packet)) { + if (FloodingRouter::send(copy) == ERRNO_SHOULD_RELEASE) + packetPool.release(copy); + } } else { #if NEXTHOP_EARLY_FLOOD_ON_UNVERIFIED // M4 (gated): if the route isn't proven healthy, don't spend a second directed @@ -416,22 +438,30 @@ int32_t NextHopRouter::doRetransmissions() meshtastic_NodeInfoLite *sentTo = nodeDB->getMeshNode(p.packet->to); if (sentTo) sentTo->next_hop = NO_NEXT_HOP_PREFERENCE; - if (auto *copy = packetPool.allocCopy(*p.packet)) - FloodingRouter::send(copy); + if (auto *copy = packetPool.allocCopy(*p.packet)) { + if (FloodingRouter::send(copy) == ERRNO_SHOULD_RELEASE) + packetPool.release(copy); + } } else { - if (auto *copy = packetPool.allocCopy(*p.packet)) - NextHopRouter::send(copy); + if (auto *copy = packetPool.allocCopy(*p.packet)) { + if (NextHopRouter::send(copy) == ERRNO_SHOULD_RELEASE) + packetPool.release(copy); + } } #else - if (auto *copy = packetPool.allocCopy(*p.packet)) - NextHopRouter::send(copy); + if (auto *copy = packetPool.allocCopy(*p.packet)) { + if (NextHopRouter::send(copy) == ERRNO_SHOULD_RELEASE) + packetPool.release(copy); + } #endif } } else { // Note: we call the superclass version because we don't want to have our version of send() add a new // retransmission record - if (auto *copy = packetPool.allocCopy(*p.packet)) - FloodingRouter::send(copy); + if (auto *copy = packetPool.allocCopy(*p.packet)) { + if (FloodingRouter::send(copy) == ERRNO_SHOULD_RELEASE) + packetPool.release(copy); + } } // Queue again diff --git a/src/mesh/NextHopRouter.h b/src/mesh/NextHopRouter.h index 467d9ca68..3a19191fe 100644 --- a/src/mesh/NextHopRouter.h +++ b/src/mesh/NextHopRouter.h @@ -137,6 +137,7 @@ class NextHopRouter : public FloodingRouter * @return true to abandon the packet */ virtual bool shouldFilterReceived(const meshtastic_MeshPacket *p) override; + bool relayOpaquePacket(const meshtastic_MeshPacket *p) override; /** * Look for packets we need to relay @@ -205,8 +206,12 @@ class NextHopRouter : public FloodingRouter */ std::optional getNextHop(NodeNum to, uint8_t relay_node); +#ifdef PIO_UNIT_TESTING + public: // expose perhapsRebroadcast to the test shim +#else private: +#endif /** Check if we should be rebroadcasting this packet if so, do so. * @return true if we did rebroadcast */ bool perhapsRebroadcast(const meshtastic_MeshPacket *p) override; -}; \ No newline at end of file +}; diff --git a/src/mesh/NodeDB.cpp b/src/mesh/NodeDB.cpp index 4c2879adc..e9e8f7d34 100644 --- a/src/mesh/NodeDB.cpp +++ b/src/mesh/NodeDB.cpp @@ -31,6 +31,9 @@ #if HAS_VARIABLE_HOPS #include "modules/HopScalingModule.h" #endif +#if HAS_TRAFFIC_MANAGEMENT +#include "modules/TrafficManagementModule.h" +#endif #include "xmodem.h" #include #include @@ -635,6 +638,7 @@ NodeDB::NodeDB() #endif sortMeshDB(); saveToDisk(saveWhat); + bootInitializationInProgress = false; } /** @@ -767,6 +771,12 @@ bool NodeDB::factoryReset(bool eraseBleBonds) warmStore.clear(); warmStore.saveIfDirty(); #endif +#if HAS_TRAFFIC_MANAGEMENT + // Factory reset forgets everything; TMM's RAM caches must not survive to resurrect + // identities (the device usually reboots after this, but don't rely on it). + if (trafficManagementModule) + trafficManagementModule->purgeAll(); +#endif // second, install default state (this will deal with the duplicate mac address issue) installDefaultNodeDatabase(); @@ -822,7 +832,7 @@ void NodeDB::installDefaultNodeDatabase() void NodeDB::installDefaultConfig(bool preserveKey = false) { uint8_t private_key_temp[32]; - bool shouldPreserveKey = preserveKey && config.has_security && config.security.private_key.size > 0; + bool shouldPreserveKey = preserveKey && config.has_security && config.security.private_key.size == 32; if (shouldPreserveKey) { memcpy(private_key_temp, config.security.private_key.bytes, config.security.private_key.size); } @@ -948,6 +958,12 @@ void NodeDB::installDefaultConfig(bool preserveKey = false) config.security.admin_key_count = numAdminKeys; + // Left at COMPATIBLE when signature checking is compiled out, so we never report a policy + // nothing enforces (mirrors the set-config guard in AdminModule). +#if defined(USERPREFS_CONFIG_SECURITY_PACKET_SIGNATURE_POLICY) && !(MESHTASTIC_EXCLUDE_PKI) && !(MESHTASTIC_EXCLUDE_XEDDSA) + config.security.packet_signature_policy = USERPREFS_CONFIG_SECURITY_PACKET_SIGNATURE_POLICY; +#endif + if (shouldPreserveKey) { config.security.private_key.size = 32; memcpy(config.security.private_key.bytes, private_key_temp, config.security.private_key.size); @@ -1597,6 +1613,11 @@ void NodeDB::resetNodes(bool keepFavorites) #if WARM_NODE_COUNT > 0 warmStore.clear(); // warm entries are never favorites; a DB reset clears them too #endif +#if HAS_TRAFFIC_MANAGEMENT + // A user-initiated DB reset forgets everything; TMM's caches must not resurrect it. + if (trafficManagementModule) + trafficManagementModule->purgeAll(); +#endif devicestate.has_rx_waypoint = false; saveNodeDatabaseToDisk(); @@ -1629,6 +1650,12 @@ void NodeDB::removeNodeByNum(NodeNum nodeNum) // Explicit user removal: don't let the warm tier resurrect the node warmStore.remove(nodeNum); #endif +#if HAS_TRAFFIC_MANAGEMENT + // Explicit removal is full removal: the TrafficManagement caches (unified slot + + // NodeInfo identity cache) must not keep serving or resurrect the node either. + if (trafficManagementModule) + trafficManagementModule->purgeNode(nodeNum); +#endif LOG_DEBUG("NodeDB::removeNodeByNum purged %d entries. Save changes", removed); saveNodeDatabaseToDisk(); @@ -1841,6 +1868,8 @@ bool NodeDB::enforceSatelliteCaps() // them if they do); otherwise tracker/sensor/tak_tracker are role-protected. static uint8_t warmProtectedCategory(const meshtastic_NodeInfoLite &n) { + if (nodeInfoLiteHasXeddsaSigned(&n)) + return static_cast(WarmProtected::XeddsaSigner); if (n.bitfield & (NODEINFO_BITFIELD_IS_FAVORITE_MASK | NODEINFO_BITFIELD_IS_IGNORED_MASK | NODEINFO_BITFIELD_IS_KEY_MANUALLY_VERIFIED_MASK)) return static_cast(WarmProtected::Flag); @@ -2165,6 +2194,41 @@ void NodeDB::loadFromDisk() migrationSavePending = false; configDecodeFailed = false; + configLoadComplete = false; + +#if !USERPREFS_EVENT_MODE +#ifdef FSCom + const char *eventProfileFiles[] = {EVENT_CONFIG_FILE_NAME, EVENT_CHANNEL_FILE_NAME, EVENT_BACKUP_FILE_NAME}; + spiLock->lock(); + for (const char *filename : eventProfileFiles) { + if (FSCom.exists(filename) && !FSCom.remove(filename)) + LOG_WARN("Unable to remove stale event profile file %s", filename); + } + spiLock->unlock(); +#endif +#endif + +#if USERPREFS_EVENT_MODE + // Seed only a missing event config; never overwrite normal files after a corrupt event config. + bool eventConfigMissing = false; + eventProfileStorageUnavailable = false; +#ifdef FSCom + spiLock->lock(); + eventConfigMissing = !FSCom.exists(configFileName); + if (eventConfigMissing) { + const size_t totalBytes = fsTotalBytes(); + const size_t usedBytes = fsUsedBytes(); + eventProfileStorageUnavailable = !hasEventProfileStorageSpace(totalBytes, usedBytes); + if (eventProfileStorageUnavailable) { + LOG_ERROR("Event profile requires %u bytes free; only %u bytes available. Profile changes will not persist.", + static_cast(EVENT_PROFILE_STORAGE_RESERVATION_BYTES), + static_cast(totalBytes >= usedBytes ? totalBytes - usedBytes : 0)); + } + } + spiLock->unlock(); +#endif + bool initializedEventConfig = false; +#endif meshtastic_Config_SecurityConfig backupSecurity = meshtastic_Config_SecurityConfig_init_zero; @@ -2354,6 +2418,31 @@ void NodeDB::loadFromDisk() state = loadProto(configFileName, meshtastic_LocalConfig_size, sizeof(meshtastic_LocalConfig), &meshtastic_LocalConfig_msg, &config); +#if USERPREFS_EVENT_MODE + if (eventConfigMissing && state != LoadFileResult::LOAD_SUCCESS) { + const LoadFileResult eventConfigState = state; + const LoadFileResult standardConfigState = + loadProto(STANDARD_CONFIG_FILE_NAME, meshtastic_LocalConfig_size, sizeof(meshtastic_LocalConfig), + &meshtastic_LocalConfig_msg, &config); + if (standardConfigState == LoadFileResult::LOAD_SUCCESS) { + // Preserve the user's identity and non-radio preferences, then + // replace only LoRa with this event build's compiled defaults. + const meshtastic_LocalConfig standardConfig = config; + installDefaultConfig(true); + const meshtastic_Config_LoRaConfig eventLora = config.lora; + config = standardConfig; + config.has_lora = true; + config.lora = eventLora; + state = LoadFileResult::LOAD_SUCCESS; + initializedEventConfig = true; + LOG_INFO("Initialized event config without modifying %s", STANDARD_CONFIG_FILE_NAME); + } else { + // Keep the event load outcome because loadProto() clears config before decoding. + // A normal decode failure must not create a replacement identity. + state = standardConfigState == LoadFileResult::DECODE_FAILED ? LoadFileResult::DECODE_FAILED : eventConfigState; + } + } +#endif if (state == LoadFileResult::DECODE_FAILED) { // Config file present but undecodable this boot (corruption / torn write / transient decrypt fail). // loadProto() already zeroed `config`, so the keypair is gone from RAM; minting a new one would change @@ -2375,6 +2464,7 @@ void NodeDB::loadFromDisk() } else { LOG_INFO("Loaded saved config version %d", config.version); } + configLoadComplete = true; // Coerce LoRa config fields derived from presets while bootstrapping. // Some clients/UI components display bandwidth/spread_factor directly from config even in preset mode. @@ -2415,6 +2505,15 @@ void NodeDB::loadFromDisk() config.lora.override_frequency = USERPREFS_LORACONFIG_OVERRIDE_FREQUENCY; #endif +#if USERPREFS_EVENT_MODE + if (initializedEventConfig) { + // This is the first durable event-profile write. A failed write is + // safe: normal files remain untouched and the next event boot retries. + if (!saveToDisk(SEGMENT_CONFIG)) + LOG_ERROR("Unable to persist initial event config"); + } +#endif + if (backupSecurity.private_key.size > 0) { LOG_DEBUG("Restoring backup of security config"); config.security = backupSecurity; @@ -2529,7 +2628,7 @@ void NodeDB::loadFromDisk() const int segments[] = {SEGMENT_CONFIG, SEGMENT_MODULECONFIG, SEGMENT_CHANNELS, SEGMENT_DEVICESTATE, SEGMENT_NODEDATABASE}; int toSave = 0; - for (int i = 0; i < 5; i++) { + for (size_t i = 0; i < sizeof(segments) / sizeof(segments[0]); i++) { if (!EncryptedStorage::isEncrypted(filesToCheck[i])) { toSave |= segments[i]; } @@ -2546,6 +2645,43 @@ void NodeDB::loadFromDisk() EncryptedStorage::migrateFile(fn); } } + + // Backups are outside saveToDisk(), but can contain radio profile PSKs. Only event builds + // introduce a second backup file, so leave normal-firmware backup handling unchanged. +#if USERPREFS_EVENT_MODE +#ifdef FSCom + spiLock->lock(); + const bool activeBackupExists = FSCom.exists(backupFileName); + spiLock->unlock(); + if (activeBackupExists && !EncryptedStorage::isEncrypted(backupFileName)) { + LOG_INFO("Migrating %s to encrypted storage", backupFileName); + if (!EncryptedStorage::migrateFile(backupFileName)) { + LOG_ERROR("Unable to migrate %s to encrypted storage", backupFileName); + storageCorruptThisLoad = true; + } + } +#endif +#endif + + // Event firmware keeps the normal radio profile inactive, so migrate it separately. +#if USERPREFS_EVENT_MODE +#ifdef FSCom + const char *inactiveRadioProfileFiles[] = {STANDARD_CONFIG_FILE_NAME, STANDARD_CHANNEL_FILE_NAME, + STANDARD_BACKUP_FILE_NAME}; + for (const char *fn : inactiveRadioProfileFiles) { + spiLock->lock(); + const bool exists = FSCom.exists(fn); + spiLock->unlock(); + if (exists && !EncryptedStorage::isEncrypted(fn)) { + LOG_INFO("Migrating inactive radio profile %s to encrypted storage", fn); + if (!EncryptedStorage::migrateFile(fn)) { + LOG_ERROR("Unable to migrate %s to encrypted storage", fn); + storageCorruptThisLoad = true; + } + } + } +#endif +#endif } #endif @@ -2586,6 +2722,11 @@ void NodeDB::loadFromDisk() moduleConfig.version = POSITION_TELEMETRY_OPTIN_VER; saveToDisk(SEGMENT_MODULECONFIG); } + + if (channels.ensureLicensedOperation()) { + LOG_WARN("Licensed operation removed persisted channel encryption/admin access"); + saveToDisk(SEGMENT_CHANNELS); + } #if ARCH_PORTDUINO // set any config overrides if (portduino_config.has_configDisplayMode) { @@ -2680,8 +2821,9 @@ bool NodeDB::disableLockdownToPlaintext() // plaintext->encrypted migrate loop above. Order does not matter here; // EncryptedStorage::removeLockdownArtifacts() (which deletes the DEK, // the commit point) only runs after every file is confirmed plaintext. - const char *filesToCheck[] = {configFileName, moduleConfigFileName, channelFileName, deviceStateFileName, - nodeDatabaseFileName}; + const char *filesToCheck[] = {STANDARD_CONFIG_FILE_NAME, STANDARD_CHANNEL_FILE_NAME, STANDARD_BACKUP_FILE_NAME, + EVENT_CONFIG_FILE_NAME, EVENT_CHANNEL_FILE_NAME, EVENT_BACKUP_FILE_NAME, + moduleConfigFileName, deviceStateFileName, nodeDatabaseFileName}; for (const char *fn : filesToCheck) { if (!EncryptedStorage::migrateFileToPlaintext(fn)) { LOG_ERROR("NodeDB: failed to revert %s to plaintext; aborting disable (device stays in lockdown)", fn); @@ -2701,6 +2843,15 @@ bool NodeDB::saveProto(const char *filename, size_t protoSize, const pb_msgdesc_ bool fullAtomic) { + // Only the radio profile is at risk from an unverified config load, so only defer those writes. + // Devicestate/nodedb/module writes must still land, otherwise boot-time recovery (e.g. loadFromDisk() + // restoring owner fields) is dropped and never retried. + if (isRadioProfileFile(filename) && + shouldDeferBootPersistence(bootInitializationInProgress, configLoadComplete, configDecodeFailed)) { + LOG_WARN("NodeDB: deferred boot write to %s until config recovery completes", filename); + return true; + } + // do not try to save anything if power level is not safe. In many cases flash will be lock-protected // and all writes will fail anyway. Device should be sleeping at this point anyway. if (!powerHAL_isPowerLevelSafe()) { @@ -2953,6 +3104,19 @@ bool NodeDB::saveToDiskNoRetry(int saveWhat) spiLock->unlock(); #endif +#if USERPREFS_EVENT_MODE + if (eventProfileStorageUnavailable) { + if (saveWhat & SEGMENT_CONFIG) { + LOG_WARN("Skipping event config write: insufficient profile storage at boot"); + saveWhat &= ~SEGMENT_CONFIG; + } + if (saveWhat & SEGMENT_CHANNELS) { + LOG_WARN("Skipping event channel write: insufficient profile storage at boot"); + saveWhat &= ~SEGMENT_CHANNELS; + } + } +#endif + if (saveWhat & SEGMENT_CONFIG) { config.has_device = true; config.has_display = true; @@ -2980,6 +3144,7 @@ bool NodeDB::saveToDiskNoRetry(int saveWhat) moduleConfig.has_audio = true; moduleConfig.has_paxcounter = true; moduleConfig.has_statusmessage = true; + moduleConfig.has_tak = true; #if !MESHTASTIC_EXCLUDE_BEACON moduleConfig.has_mesh_beacon = true; #endif @@ -3120,6 +3285,9 @@ size_t NodeDB::getNumOnlineMeshNodes(bool localOnly) #include "MeshModule.h" #include "Throttle.h" +// Minimum spacing between evictions once the node database is full. +#define NODEDB_FULL_EVICTION_INTERVAL_MS (2 * 1000UL) + static constexpr uint32_t HOPSTART_DROP_LOG_INTERVAL_MS = 15000; void logHopStartDrop(const meshtastic_MeshPacket &p, const char *context) @@ -3268,6 +3436,9 @@ void NodeDB::addFromContact(meshtastic_SharedContact contact) LOG_WARN(PROTECTED_CAP_WARN_FMT, "ignore", contact.node_num, MAX_NUM_NODES - 2); nodeInfoLiteSetBit(info, NODEINFO_BITFIELD_IS_FAVORITE_MASK, false); eraseNodeSatellites(contact.node_num); +#if HAS_SCREEN || defined(MESHTASTIC_INCLUDE_NICHE_GRAPHICS) + messageStore.deleteAllMessagesFromNode(contact.node_num); +#endif } else { /* Clients are sending add_contact before every text message DM (because clients may hold a larger node database with * public keys than the radio holds). However, we don't want to update last_heard just because we sent someone a DM! @@ -3307,8 +3478,16 @@ void NodeDB::addFromContact(meshtastic_SharedContact contact) /** Update user info and channel for this node based on received user data */ -bool NodeDB::updateUser(uint32_t nodeId, meshtastic_User &p, uint8_t channelIndex) +bool NodeDB::updateUser(uint32_t nodeId, meshtastic_User &p, uint8_t channelIndex, bool xeddsaSigned) { + // Only a signed update may change the identity of a node that has proven it signs; our own record is + // exempt. Checked before getOrCreateMeshNode so a refused update cannot evict or write the warm tier. + const meshtastic_NodeInfoLite *existing = getMeshNode(nodeId); + if (nodeId != getNodeNum() && existing && nodeInfoLiteHasXeddsaSigned(existing) && !xeddsaSigned) { + LOG_WARN("Refusing unsigned identity update for node 0x%08x that previously signed", nodeId); + return false; + } + meshtastic_NodeInfoLite *info = getOrCreateMeshNode(nodeId); if (!info) { return false; @@ -3390,6 +3569,20 @@ bool NodeDB::updateUser(uint32_t nodeId, meshtastic_User &p, uint8_t channelInde } } +#if HAS_TRAFFIC_MANAGEMENT + // Write-through: every accepted remote-identity commit lands here (NodeInfoModule, + // MeshService, and TMM's requester learning all funnel through updateUser; the two + // key-write sites that bypass it call onNodeKeyCommitted instead), so TMM's NodeInfo + // cache reflects the commit immediately rather than at the next reconcile pass. Runs on + // acceptance, not on `changed`: an identical update still proves the identity is + // current. `p` is the post-hygiene payload; signerKnown transfers only key-matched + // verified-signer status (isVerifiedSignerForKey semantics), never a bare node flag. + if (nodeId != getNodeNum() && trafficManagementModule) { + const bool signerKnown = p.public_key.size == 32 && isVerifiedSignerForKey(nodeId, p.public_key.bytes); + trafficManagementModule->onNodeIdentityCommitted(nodeId, p, signerKnown); + } +#endif + return changed; } @@ -3404,7 +3597,19 @@ void NodeDB::updateFrom(const meshtastic_MeshPacket &mp) if (mp.which_payload_variant == meshtastic_MeshPacket_decoded_tag && mp.from) { LOG_DEBUG("Update DB node 0x%08x, rx_time=%u", mp.from, mp.rx_time); - meshtastic_NodeInfoLite *info = getOrCreateMeshNode(getFrom(&mp)); + // mp.from is unauthenticated, so rate-limit admission once the database is full: otherwise + // invented node numbers churn it at packet rate and push real neighbours out. + meshtastic_NodeInfoLite *info = getMeshNode(getFrom(&mp)); + if (!info) { + if (isFull()) { + if (Throttle::isWithinTimespanMs(lastFullEvictionMs, NODEDB_FULL_EVICTION_INTERVAL_MS)) { + LOG_DEBUG("Node database full, defer admitting 0x%08x", mp.from); + return; + } + lastFullEvictionMs = millis(); + } + info = getOrCreateMeshNode(getFrom(&mp)); + } if (!info) { return; } @@ -3688,7 +3893,7 @@ uint32_t NodeDB::hotNodeLastHeard(NodeNum n) const return 0; } -bool NodeDB::copyPublicKey(NodeNum n, meshtastic_NodeInfoLite_public_key_t &out) +bool NodeDB::copyPublicKeyAuthoritative(NodeNum n, meshtastic_NodeInfoLite_public_key_t &out) { const meshtastic_NodeInfoLite *info = getMeshNode(n); if (info && info->public_key.size == 32) { @@ -3704,6 +3909,97 @@ bool NodeDB::copyPublicKey(NodeNum n, meshtastic_NodeInfoLite_public_key_t &out) return false; } +bool NodeDB::copyPublicKey(NodeNum n, meshtastic_NodeInfoLite_public_key_t &out) +{ + if (copyPublicKeyAuthoritative(n, out)) + return true; +#if HAS_TRAFFIC_MANAGEMENT + // Last resort: a key the TrafficManagement NodeInfo cache learned from an observed frame + // for a node no longer in either NodeDB tier. This extends the pool of peers we can + // encrypt to. Keys here may be trust-on-first-use (see copyPublicKey's signerProven), the + // same first-contact trust NodeDB itself applies via updateUser(). + if (trafficManagementModule && trafficManagementModule->copyPublicKey(n, out.bytes)) { + out.size = 32; + return true; + } +#endif + return false; +} + +bool NodeDB::copyPublicKeyForDecrypt(NodeNum n, meshtastic_NodeInfoLite_public_key_t &out) +{ + if (copyPublicKeyAuthoritative(n, out)) + return true; +#if HAS_TRAFFIC_MANAGEMENT + // A cold-tier cache key backs an authenticated decrypt only when signer-proven; unverified TOFU + // cache keys must not. Outbound encryption still uses the opportunistic copyPublicKey(). + bool signerProven = false; + if (trafficManagementModule && trafficManagementModule->copyPublicKey(n, out.bytes, &signerProven) && signerProven) { + out.size = 32; + return true; + } +#endif + return false; +} + +bool NodeDB::isVerifiedSignerForKey(NodeNum n, const uint8_t *key32) +{ + if (!key32) + return false; + // Hot store is authoritative when present; a node lives in the hot XOR warm tier, so if the + // hot store holds it the warm tier does not, and we decide entirely from the hot entry. + const meshtastic_NodeInfoLite *info = getMeshNode(n); + if (info) + return info->public_key.size == 32 && nodeInfoLiteHasXeddsaSigned(info) && memcmp(info->public_key.bytes, key32, 32) == 0; +#if WARM_NODE_COUNT > 0 + uint8_t warmKey[32]; + if (warmStore.copyKey(n, warmKey) && memcmp(warmKey, key32, 32) == 0) + return warmStore.isVerifiedSigner(n); +#endif + return false; +} + +bool NodeDB::isKnownXeddsaSigner(NodeNum n) +{ + // A node lives in the hot XOR warm tier, so the hot verdict is final when present. + const meshtastic_NodeInfoLite *info = getMeshNode(n); + if (info) + return nodeInfoLiteHasXeddsaSigned(info); +#if WARM_NODE_COUNT > 0 + return warmStore.isVerifiedSigner(n); +#else + return false; +#endif +} + +void NodeDB::commitRemoteKey(NodeNum n, const uint8_t key32[32], KeyCommitTrust trust) +{ + if (!key32 || n == 0) + return; + // Local copy first: callers may pass the node's own key bytes back in (e.g. manual + // verification re-committing an already-stored key), and memcpy forbids overlap. + uint8_t key[32]; + memcpy(key, key32, 32); + + meshtastic_NodeInfoLite *info = getOrCreateMeshNode(n); + if (!info) + return; + // Unconditional overwrite - deliberately NOT updateUser()'s "don't replace a known key" pin. + // That pin protects against unauthenticated NodeInfo broadcasts; the only callers here are + // possession/authority-proven (ManuallyVerified = user confirmed the key; AdminChannelProven = + // decrypted via the admin key with p->from bound into the AEAD nonce), i.e. exactly the paths + // meant to establish or rotate a key. Keep new call sites to that same trust bar. + memcpy(info->public_key.bytes, key, 32); + info->public_key.size = 32; + +#if HAS_TRAFFIC_MANAGEMENT + // Write-through, mirroring updateUser()'s identity hook: without it the TrafficManagement + // NodeInfo cache diverges until the next hourly reconcile. + if (trafficManagementModule) + trafficManagementModule->onNodeKeyCommitted(n, key, trust == KeyCommitTrust::ManuallyVerified); +#endif +} + meshtastic_Config_DeviceConfig_Role NodeDB::getNodeRole(NodeNum n) { const meshtastic_NodeInfoLite *info = getMeshNode(n); @@ -3799,8 +4095,27 @@ meshtastic_NodeInfoLite *NodeDB::getOrCreateMeshNode(NodeNum n) lite->public_key.size = 32; memcpy(lite->public_key.bytes, warm.public_key, 32); } + if (warmProtOf(warm) == static_cast(WarmProtected::XeddsaSigner)) + nodeInfoLiteSetBit(lite, NODEINFO_BITFIELD_HAS_XEDDSA_SIGNED_MASK, true); LOG_MIGRATION("Rehydrated node 0x%08x from warm tier (key=%d)", n, lite->public_key.size == 32); } +#endif +#if HAS_TRAFFIC_MANAGEMENT + // Name rehydration: the warm tier keeps a node's key but not its name, so a re-admitted + // long-tail node is nameless until its next NodeInfo. The TrafficManagement NodeInfo + // cache is much larger and often still holds the full User. Restore it - but only when + // its cached key matches the key we just restored from warm, so a name never attaches to + // a different identity than the one we encrypt to. No-op without the TMM NodeInfo cache + // or when no key is present (key-matched by design). CopyUserToNodeInfoLite sets only the + // user-related bits, so the warm-restored signer bit survives. + if (lite->public_key.size == 32 && !nodeInfoLiteHasUser(lite) && trafficManagementModule) { + meshtastic_User tmmUser = meshtastic_User_init_zero; + if (trafficManagementModule->copyUser(n, tmmUser) && tmmUser.public_key.size == 32 && + memcmp(tmmUser.public_key.bytes, lite->public_key.bytes, 32) == 0) { + TypeConversions::CopyUserToNodeInfoLite(lite, tmmUser); + LOG_INFO("Rehydrated node 0x%08x identity from TMM NodeInfo cache", n); + } + } #endif LOG_INFO("Adding node to database with %i nodes and %u bytes free!", numMeshNodes, memGet.getFreeHeap()); } @@ -3851,15 +4166,14 @@ bool NodeDB::checkLowEntropyPublicKey(const meshtastic_Config_SecurityConfig_pub bool NodeDB::generateCryptoKeyPair(const uint8_t *privateKey) { #if !(MESHTASTIC_EXCLUDE_PKI_KEYGEN || MESHTASTIC_EXCLUDE_PKI) - // Only generate keys for non-licensed users and if the LoRa region is set. The native simulator - // boots region-UNSET but still needs a keypair so PKI-encrypted DMs work between sim nodes, so - // allow keygen there regardless of region. + // Generate identity keys once a LoRa region is set. Licensed operation still needs the identity + // key for plaintext signatures, even though the key is never used for PKI encryption. bool regionBlocksKeygen = config.lora.region == meshtastic_Config_LoRaConfig_RegionCode_UNSET; #if ARCH_PORTDUINO if (portduino_config.lora_module == use_simradio) regionBlocksKeygen = false; #endif - if (owner.is_licensed || regionBlocksKeygen) { + if (regionBlocksKeygen) { return false; } @@ -3909,8 +4223,8 @@ bool NodeDB::generateCryptoKeyPair(const uint8_t *privateKey) LOG_DEBUG("Set DH private key for crypto operations"); crypto->setDHPrivateKey(config.security.private_key.bytes); - // Conditionally create new identity based on parameter - createNewIdentity(); + if (createNewIdentity() && owner.is_licensed) + licensedIdentityMigrationPending = true; } return keygenSuccess; #else @@ -3918,6 +4232,21 @@ bool NodeDB::generateCryptoKeyPair(const uint8_t *privateKey) #endif } +bool NodeDB::notifyPendingLicensedIdentityMigration() +{ + if (!licensedIdentityMigrationPending || !service) + return false; + meshtastic_ClientNotification *notification = clientNotificationPool.allocZeroed(); + if (!notification) + return false; + notification->level = meshtastic_LogRecord_Level_WARNING; + notification->time = getValidTime(RTCQualityFromNet); + snprintf(notification->message, sizeof(notification->message), "%s", LICENSED_IDENTITY_MIGRATION_WARNING); + service->sendClientNotification(notification); + licensedIdentityMigrationPending = false; + return true; +} + bool NodeDB::createNewIdentity() { uint32_t oldNodeNum = getNodeNum(); @@ -4021,6 +4350,13 @@ bool NodeDB::restorePreferences(meshtastic_AdminMessage_BackupLocation location, LOG_DEBUG("Restored channels"); } + if (owner.is_licensed && channels.ensureLicensedOperation()) { + restoreWhat |= SEGMENT_CHANNELS; + LOG_WARN("Licensed operation sanitized restored channel encryption/admin access"); + } + if (restoreWhat & SEGMENT_CHANNELS) + channels.onConfigChanged(); + success = saveToDisk(restoreWhat); if (success) { LOG_INFO("Restored preferences from backup"); diff --git a/src/mesh/NodeDB.h b/src/mesh/NodeDB.h index f7494811f..cb93754a6 100644 --- a/src/mesh/NodeDB.h +++ b/src/mesh/NodeDB.h @@ -74,6 +74,8 @@ static const uint8_t LOW_ENTROPY_HASHES[][32] = { 0x37, 0x82, 0x8d, 0xb2, 0xcc, 0xd8, 0x97, 0x40, 0x9a, 0x5c, 0x8f, 0x40, 0x55, 0xcb, 0x4c, 0x3e}}; static const char LOW_ENTROPY_WARNING[] = "Compromised keys were detected and regenerated."; #endif +static const char LICENSED_IDENTITY_MIGRATION_WARNING[] = + "Licensed signing generated a new identity key; this node identity changed."; /* DeviceState versions used to be defined in the .proto file but really only this function cares. So changed to a #define here. @@ -112,11 +114,57 @@ extern meshtastic_Position localPosition; static constexpr const char *deviceStateFileName = "/prefs/device.proto"; static constexpr const char *legacyPrefFileName = "/prefs/db.proto"; static constexpr const char *nodeDatabaseFileName = "/prefs/nodes.proto"; -static constexpr const char *configFileName = "/prefs/config.proto"; +// Event builds isolate radio profiles so normal firmware resumes its config and channels after OTA. +static constexpr const char *STANDARD_CONFIG_FILE_NAME = "/prefs/config.proto"; +static constexpr const char *STANDARD_CHANNEL_FILE_NAME = "/prefs/channels.proto"; +static constexpr const char *EVENT_CONFIG_FILE_NAME = "/prefs/event-config.proto"; +static constexpr const char *EVENT_CHANNEL_FILE_NAME = "/prefs/event-channels.proto"; +static constexpr const char *STANDARD_BACKUP_FILE_NAME = "/backups/backup.proto"; +static constexpr const char *EVENT_BACKUP_FILE_NAME = "/backups/event-backup.proto"; + +struct RadioProfileStoragePaths { + const char *config; + const char *channels; + const char *backup; +}; + +constexpr RadioProfileStoragePaths radioProfileStoragePaths(bool eventMode) +{ + return eventMode ? RadioProfileStoragePaths{EVENT_CONFIG_FILE_NAME, EVENT_CHANNEL_FILE_NAME, EVENT_BACKUP_FILE_NAME} + : RadioProfileStoragePaths{STANDARD_CONFIG_FILE_NAME, STANDARD_CHANNEL_FILE_NAME, STANDARD_BACKUP_FILE_NAME}; +} + +// Reserve event files and their atomic temporaries before seeding, preventing retry formatting on full storage. +static constexpr size_t EVENT_PROFILE_STORAGE_RESERVATION_BYTES = 2 * (meshtastic_LocalConfig_size + meshtastic_ChannelFile_size); + +constexpr bool hasEventProfileStorageSpace(size_t totalBytes, size_t usedBytes) +{ + return usedBytes <= totalBytes && totalBytes - usedBytes >= EVENT_PROFILE_STORAGE_RESERVATION_BYTES; +} + +constexpr bool shouldDeferBootPersistence(bool bootInitializationInProgress, bool configLoadComplete, bool configDecodeFailed) +{ + return bootInitializationInProgress && (!configLoadComplete || configDecodeFailed); +} + +#if USERPREFS_EVENT_MODE +static constexpr auto RADIO_PROFILE_STORAGE = radioProfileStoragePaths(true); +#else +static constexpr auto RADIO_PROFILE_STORAGE = radioProfileStoragePaths(false); +#endif +static constexpr const char *configFileName = RADIO_PROFILE_STORAGE.config; +static constexpr const char *channelFileName = RADIO_PROFILE_STORAGE.channels; +static constexpr const char *backupFileName = RADIO_PROFILE_STORAGE.backup; static constexpr const char *uiconfigFileName = "/prefs/uiconfig.proto"; static constexpr const char *moduleConfigFileName = "/prefs/module.proto"; -static constexpr const char *channelFileName = "/prefs/channels.proto"; -static constexpr const char *backupFileName = "/backups/backup.proto"; + +// An unverified config load only endangers the radio profile, so only these files take part in +// boot-write deferral. Lives next to the path table above so the two cannot drift apart. +inline bool isRadioProfileFile(const char *filename) +{ + return strcmp(filename, configFileName) == 0 || strcmp(filename, channelFileName) == 0 || + strcmp(filename, backupFileName) == 0; +} /// Given a node, return how many seconds in the past (vs now) that we last heard from it uint32_t sinceLastSeen(const meshtastic_NodeInfoLite *n); @@ -221,6 +269,7 @@ class NodeDB bool keyIsLowEntropy = false; bool hasWarned = false; + bool licensedIdentityMigrationPending = false; /// don't do mesh based algorithm for node id assignment (initially) /// instead just store in flash - possibly even in the initial alpha release do this hack @@ -255,9 +304,9 @@ class NodeDB */ void updateTelemetry(uint32_t nodeId, const meshtastic_Telemetry &t, RxSource src = RX_SRC_RADIO); - /** Update user info and channel for this node based on received user data - */ - bool updateUser(uint32_t nodeId, meshtastic_User &p, uint8_t channelIndex = 0); + /** Update user info and channel for this node based on received user data. + * A known signer's identity is only learned when xeddsaSigned; defaults false so callers fail closed. */ + bool updateUser(uint32_t nodeId, meshtastic_User &p, uint8_t channelIndex = 0, bool xeddsaSigned = false); /* * Sets a node either favorite or unfavorite. Returns true if the node ends @@ -360,6 +409,37 @@ class NodeDB /// tier. Returns false if we don't know a key for n. bool copyPublicKey(NodeNum n, meshtastic_NodeInfoLite_public_key_t &out); + /// Copy the 32-byte key for n from the AUTHORITATIVE tiers only (hot, then warm; never + /// opportunistic caches) - the pin reference for caches that mirror NodeDB's key hygiene. + bool copyPublicKeyAuthoritative(NodeNum n, meshtastic_NodeInfoLite_public_key_t &out); + + /// Key for the inbound-decrypt path: authoritative (hot/warm), or a cold-tier cache key only when + /// it is signer-proven. Keeps unverified TOFU cache keys from backing pki_encrypted attribution. + bool copyPublicKeyForDecrypt(NodeNum n, meshtastic_NodeInfoLite_public_key_t &out); + + /// True if n is a known XEdDSA signer for exactly `key32` (hot signed bitfield or warm + /// signer bit); the key match stops a rotated key inheriting a stale signer verdict. + bool isVerifiedSignerForKey(NodeNum n, const uint8_t *key32); + + /// Key-agnostic "should n's signable traffic arrive signed", per hot bitfield or warm signer + /// bit - hot-only gates would let a warm-evicted signer be impersonated with unsigned frames. + bool isKnownXeddsaSigner(NodeNum n); + + /// Provenance of a bare-key commit that deliberately bypasses updateUser()'s + /// User-payload / TOFU-pin path. Maps to the TrafficManagement cache's `proven` flag: + /// only ManuallyVerified vouches for possession of exactly this key. + enum class KeyCommitTrust : uint8_t { + AdminChannelProven, // possession shown to the admin channel (AEAD) - TOFU-grade for signing + ManuallyVerified, // the user confirmed possession of exactly this key + }; + + /// THE primitive for key writes that bypass updateUser() (no User payload; provenance + /// differs from a received NodeInfo): writes the 32-byte key to the hot store and + /// write-through to the TrafficManagement NodeInfo cache. Any future direct key-write + /// site must call this rather than assigning info->public_key, or the TrafficManagement + /// cache silently diverges until the next hourly reconcile. + void commitRemoteKey(NodeNum n, const uint8_t key32[32], KeyCommitTrust trust); + /// Resolve a node's device role - hot store (with user) first, then the role /// cached in the warm tier, else CLIENT. Lets role-aware policy keep firing for /// nodes that have aged out of the hot store. @@ -474,6 +554,8 @@ class NodeDB /// @param privateKey Optional 32-byte private key to use. If nullptr, generates new random keys. bool generateCryptoKeyPair(const uint8_t *privateKey = nullptr); + bool notifyPendingLicensedIdentityMigration(); + bool createNewIdentity(); bool backupPreferences(meshtastic_AdminMessage_BackupLocation location); @@ -529,9 +611,18 @@ class NodeDB /// skip boot keygen and skip persisting defaults, so a transient read failure can't change our NodeNum /// or overwrite the on-disk config. Cleared at the top of every loadFromDisk() run. bool configDecodeFailed = false; - uint32_t lastNodeDbSave = 0; // when we last saved our db to flash - uint32_t lastBackupAttempt = 0; // when we last tried a backup automatically or manually - uint32_t lastSort = 0; // When last sorted the nodeDB + // Defer automatic writes until config load is healthy to protect device and node data from damaged configs. + bool bootInitializationInProgress = true; + bool configLoadComplete = false; +#if USERPREFS_EVENT_MODE + // The active event profile is intentionally non-durable when there was not + // enough room to create it safely at boot. A later boot retries the check. + bool eventProfileStorageUnavailable = false; +#endif + uint32_t lastNodeDbSave = 0; // when we last saved our db to flash + uint32_t lastFullEvictionMs = 0; // when we last evicted to admit a new node, once the db is full + uint32_t lastBackupAttempt = 0; // when we last tried a backup automatically or manually + uint32_t lastSort = 0; // When last sorted the nodeDB /* * Internal boolean to track sorting paused @@ -548,6 +639,7 @@ class NodeDB // Grant the unit-test shim access to the private maintenance paths below // (migration / cleanup / eviction) without relaxing production access. friend class NodeDBTestShim; + friend class MockNodeDB; #endif /// purge db entries without user info diff --git a/src/mesh/PhoneAPI.cpp b/src/mesh/PhoneAPI.cpp index f42e3dbb3..28d047f13 100644 --- a/src/mesh/PhoneAPI.cpp +++ b/src/mesh/PhoneAPI.cpp @@ -887,6 +887,11 @@ size_t PhoneAPI::getFromRadio(uint8_t *buf) fromRadioScratch.moduleConfig.which_payload_variant = meshtastic_ModuleConfig_paxcounter_tag; fromRadioScratch.moduleConfig.payload_variant.paxcounter = moduleConfig.paxcounter; break; + case meshtastic_ModuleConfig_statusmessage_tag: + LOG_DEBUG("Send module config: status message"); + fromRadioScratch.moduleConfig.which_payload_variant = meshtastic_ModuleConfig_statusmessage_tag; + fromRadioScratch.moduleConfig.payload_variant.statusmessage = moduleConfig.statusmessage; + break; case meshtastic_ModuleConfig_traffic_management_tag: LOG_DEBUG("Send module config: traffic management"); fromRadioScratch.moduleConfig.which_payload_variant = meshtastic_ModuleConfig_traffic_management_tag; diff --git a/src/mesh/ProtobufModule.h b/src/mesh/ProtobufModule.h index 42d80d5d6..1d1441c4d 100644 --- a/src/mesh/ProtobufModule.h +++ b/src/mesh/ProtobufModule.h @@ -44,6 +44,8 @@ template class ProtobufModule : protected SinglePortModule { // Update our local node info with our position (even if we don't decide to update anyone else) meshtastic_MeshPacket *p = allocDataPacket(); + if (!p) + return nullptr; p->decoded.payload.size = pb_encode_to_bytes(p->decoded.payload.bytes, sizeof(p->decoded.payload.bytes), fields, &payload); diff --git a/src/mesh/RadioInterface.cpp b/src/mesh/RadioInterface.cpp index b577a7f35..043bd59ef 100644 --- a/src/mesh/RadioInterface.cpp +++ b/src/mesh/RadioInterface.cpp @@ -55,6 +55,34 @@ static const meshtastic_Config_LoRaConfig_ModemPreset PRESETS_NARROW[] = {PRESET static const meshtastic_Config_LoRaConfig_ModemPreset PRESETS_TINY[] = {PRESET(TINY_FAST), PRESET(TINY_SLOW), MODEM_PRESET_END}; +// The EU_868/EU_866/EU_N_868 trio share the 868 band but own mutually exclusive preset +// profiles. Selecting a preset locked to a sibling swaps the region to that sibling (see +// regionSwapForPreset), so from any region in the trio every one of these presets is +// selectable. This union is what we advertise to clients as the trio's legal list. It is a +// display-only superset: on-device enforcement still uses each region's own disjoint +// profile->presets, so this must never be assigned to a RegionProfile (that would make +// supportsPreset() accept the preset in place and defeat the swap). Keep in sync with the +// EU_868/EU_866/EU_N_868 profile lists below. Sized to the 11-preset wire cap. +static const meshtastic_Config_LoRaConfig_ModemPreset PRESETS_EU_SUPERSET[] = { + PRESET(LONG_FAST), PRESET(LONG_SLOW), PRESET(MEDIUM_SLOW), PRESET(MEDIUM_FAST), PRESET(SHORT_SLOW), PRESET(SHORT_FAST), + PRESET(LONG_MODERATE), PRESET(LITE_FAST), PRESET(LITE_SLOW), PRESET(NARROW_FAST), PRESET(NARROW_SLOW), MODEM_PRESET_END}; + +// The EU_868/EU_866/EU_N_868 trio own mutually exclusive preset lists. Selecting a preset +// locked to a sibling means the user wants that sibling region, not the default preset. +static const meshtastic_Config_LoRaConfig_RegionCode SWAPPABLE_EU_REGIONS[] = { + meshtastic_Config_LoRaConfig_RegionCode_EU_868, + meshtastic_Config_LoRaConfig_RegionCode_EU_866, + meshtastic_Config_LoRaConfig_RegionCode_EU_N_868, +}; + +static bool isSwappableEuRegion(meshtastic_Config_LoRaConfig_RegionCode code) +{ + for (auto c : SWAPPABLE_EU_REGIONS) + if (c == code) + return true; + return false; +} + // Region profiles: bundle preset list + regulatory parameters shared across regions // presets, spacing, padding, audio, licensed, text throttle, position throttle, telemetry throttle const RegionProfile PROFILE_STD = {PRESETS_STD, 0, 0, true, false, 0, 1, 1}; @@ -687,8 +715,13 @@ void getRegionPresetMap(meshtastic_LoRaRegionPresetMap &map) grp.default_preset = r->getDefaultPreset(); grp.licensed_only = r->profile->licensedOnly; grp.presets_count = 0; - for (size_t i = 0; r->profile->presets[i] != MODEM_PRESET_END && grp.presets_count < maxPresets; i++) - grp.presets[grp.presets_count++] = r->profile->presets[i]; + // EU 86x siblings advertise the trio's superset - any of those presets is + // reachable from here via an automatic region swap. Every other region + // advertises exactly its own enforced profile list. + const meshtastic_Config_LoRaConfig_ModemPreset *advertised = + isSwappableEuRegion(r->code) ? PRESETS_EU_SUPERSET : r->profile->presets; + for (size_t i = 0; advertised[i] != MODEM_PRESET_END && grp.presets_count < maxPresets; i++) + grp.presets[grp.presets_count++] = advertised[i]; } // Map this region to its group (capacity checked at the top of the loop). @@ -963,14 +996,6 @@ static void sendErrorNotification(const char *msg, meshtastic_LogRecord_Level le service->sendClientNotification(cn); } -// The EU_868/EU_866/EU_N_868 trio own mutually exclusive preset lists. Selecting a preset -// locked to a sibling means the user wants that sibling region, not the default preset. -static const meshtastic_Config_LoRaConfig_RegionCode SWAPPABLE_EU_REGIONS[] = { - meshtastic_Config_LoRaConfig_RegionCode_EU_868, - meshtastic_Config_LoRaConfig_RegionCode_EU_866, - meshtastic_Config_LoRaConfig_RegionCode_EU_N_868, -}; - /** * If currentRegion is one of the swappable EU regions and preset belongs to a sibling in * that trio, return the sibling region that owns the preset. Returns nullptr otherwise. @@ -978,12 +1003,7 @@ static const meshtastic_Config_LoRaConfig_RegionCode SWAPPABLE_EU_REGIONS[] = { const RegionInfo *RadioInterface::regionSwapForPreset(meshtastic_Config_LoRaConfig_RegionCode currentRegion, meshtastic_Config_LoRaConfig_ModemPreset preset) { - bool currentIsSwappable = false; - for (auto code : SWAPPABLE_EU_REGIONS) { - if (code == currentRegion) - currentIsSwappable = true; - } - if (!currentIsSwappable) + if (!isSwappableEuRegion(currentRegion)) return nullptr; for (auto code : SWAPPABLE_EU_REGIONS) { @@ -1002,7 +1022,8 @@ const RegionInfo *RadioInterface::regionSwapForPreset(meshtastic_Config_LoRaConf * receives the human-readable failure reason. * Returns false if not compatible. */ -bool RadioInterface::checkConfigRegion(const meshtastic_Config_LoRaConfig &loraConfig, char *errBuf, size_t errLen) +bool RadioInterface::checkConfigRegion(const meshtastic_Config_LoRaConfig &loraConfig, char *errBuf, size_t errLen, + bool prospectiveLicensedOwner) { const RegionInfo *newRegion = getRegion(loraConfig.region); @@ -1014,7 +1035,7 @@ bool RadioInterface::checkConfigRegion(const meshtastic_Config_LoRaConfig &loraC } // If you are not licensed, you can't use ham regions. - if (newRegion->profile->licensedOnly && !devicestate.owner.is_licensed) { + if (newRegion->profile->licensedOnly && !devicestate.owner.is_licensed && !prospectiveLicensedOwner) { if (errBuf) snprintf(errBuf, errLen, "Region %s requires licensed mode", newRegion->name); return false; diff --git a/src/mesh/RadioInterface.h b/src/mesh/RadioInterface.h index 595426746..eb9315ac1 100644 --- a/src/mesh/RadioInterface.h +++ b/src/mesh/RadioInterface.h @@ -256,8 +256,10 @@ class RadioInterface static bool checkOrClampConfigLora(meshtastic_Config_LoRaConfig &loraConfig, bool clamp); // Check if a candidate region is compatible and valid, with no side effects (safe for - // speculative UI checks). errBuf, if given, receives the failure reason. - static bool checkConfigRegion(const meshtastic_Config_LoRaConfig &loraConfig, char *errBuf = nullptr, size_t errLen = 0); + // speculative UI checks). prospectiveLicensedOwner is for a UI flow that requires + // confirmation before it sets the owner licensed. errBuf, if given, receives the failure reason. + static bool checkConfigRegion(const meshtastic_Config_LoRaConfig &loraConfig, char *errBuf = nullptr, size_t errLen = 0, + bool prospectiveLicensedOwner = false); // Check if a candidate region is compatible and valid. On failure, logs at ERROR, // records a critical error, and sends a client notification. diff --git a/src/mesh/Router.cpp b/src/mesh/Router.cpp index 9b1cc3ff2..2ec9b43b3 100644 --- a/src/mesh/Router.cpp +++ b/src/mesh/Router.cpp @@ -12,9 +12,10 @@ #include "mesh-pb-constants.h" #include "meshUtils.h" #include "modules/RoutingModule.h" +#include +#include #include #if HAS_TRAFFIC_MANAGEMENT -#include "modules/TrafficManagementModule.h" #endif #if HAS_VARIABLE_HOPS #include "modules/HopScalingModule.h" @@ -67,6 +68,79 @@ Allocator &packetPool = staticPool; static uint8_t bytes[MAX_LORA_PAYLOAD_LEN + 1] __attribute__((__aligned__)); +struct RoutingAuthCache { + bool valid = false; + // Deliberately NOT initialized in-class as this eats flash space. + meshtastic_Config_SecurityConfig_PacketSignaturePolicy policy; + meshtastic_MeshPacket wire = meshtastic_MeshPacket_init_zero; + meshtastic_MeshPacket authenticated = meshtastic_MeshPacket_init_zero; +}; +static RoutingAuthCache routingAuthCache; +static concurrency::Lock *routingAuthCacheLock; +static uint32_t routingAuthEvaluations; + +static bool routingAuthCacheMatches(const meshtastic_MeshPacket &packet) +{ + if (!routingAuthCacheLock) + return false; + concurrency::LockGuard guard(routingAuthCacheLock); + if (!routingAuthCache.valid) + return false; + if (routingAuthCache.policy != config.security.packet_signature_policy || + memcmp(&routingAuthCache.wire, &packet, sizeof(packet)) != 0) { + routingAuthCache.valid = false; + return false; + } + return true; +} + +static void storeRoutingAuthCache(const meshtastic_MeshPacket &wire, const meshtastic_MeshPacket &authenticated) +{ + concurrency::LockGuard guard(routingAuthCacheLock); + routingAuthCache.wire = wire; + routingAuthCache.authenticated = authenticated; + routingAuthCache.policy = config.security.packet_signature_policy; + routingAuthCache.valid = true; +} + +static bool applyRoutingAuthCache(meshtastic_MeshPacket *packet) +{ + if (!routingAuthCacheLock) + return false; + concurrency::LockGuard guard(routingAuthCacheLock); + if (!routingAuthCache.valid || routingAuthCache.policy != config.security.packet_signature_policy || + memcmp(&routingAuthCache.wire, packet, sizeof(*packet)) != 0) { + routingAuthCache.valid = false; + return false; + } + *packet = routingAuthCache.authenticated; + routingAuthCache.valid = false; + return true; +} + +static void clearRoutingAuthCache() +{ + if (!routingAuthCacheLock) + return; + concurrency::LockGuard guard(routingAuthCacheLock); + routingAuthCache.valid = false; +} + +#ifdef PIO_UNIT_TESTING +uint32_t routingAuthEvaluationCount() +{ + return routingAuthEvaluations; +} +void resetRoutingAuthEvaluationCount() +{ + routingAuthEvaluations = 0; + if (routingAuthCacheLock) { + concurrency::LockGuard guard(routingAuthCacheLock); + routingAuthCache.valid = false; + } +} +#endif + /** * Constructor * @@ -85,6 +159,10 @@ Router::Router() : concurrency::OSThread("Router"), fromRadioQueue(MAX_RX_FROMRA // init Lockguard for crypt operations assert(!cryptLock); cryptLock = new concurrency::Lock(); + if (!routingAuthCacheLock) + routingAuthCacheLock = new concurrency::Lock(); + // Runtime default for the auth-cache snapshot policy. Keep it here, saves flash. + routingAuthCache.policy = meshtastic_Config_SecurityConfig_PacketSignaturePolicy_PACKET_SIGNATURE_POLICY_BALANCED; } bool Router::shouldDecrementHopLimit(const meshtastic_MeshPacket *p) @@ -194,6 +272,8 @@ PacketId generatePacketId() meshtastic_MeshPacket *Router::allocForSending() { meshtastic_MeshPacket *p = packetPool.allocZeroed(); + if (!p) + return nullptr; p->which_payload_variant = meshtastic_MeshPacket_decoded_tag; // Assume payload is decoded at start. p->from = nodeDB->getNodeNum(); @@ -247,8 +327,10 @@ ErrorCode Router::sendLocal(meshtastic_MeshPacket *p, RxSource src) // No need to deliver externally if the destination is the local node if (isToUs(p)) { printPacket("Enqueued local", p); - enqueueReceivedMessage(p); - return ERRNO_OK; + // Preserve the trusted origin explicitly. Queueing used to erase src and make a local + // phone/module packet indistinguishable from remote already-decoded ingress. + deliverLocal(p, src); + return ERRNO_SHOULD_RELEASE; } else if (!iface) { // We must be sending to remote nodes also, fail if no interface found abortSendAndNak(meshtastic_Routing_Error_NO_INTERFACE, p); @@ -256,9 +338,10 @@ ErrorCode Router::sendLocal(meshtastic_MeshPacket *p, RxSource src) return ERRNO_NO_INTERFACES; } else { // If we are sending a broadcast, we also treat it as if we just received it ourself - // this allows local apps (and PCs) to see broadcasts sourced locally + // this allows local apps (and PCs) to see broadcasts sourced locally. Only the loopback + // handleReceived is deferred when nested; send(p) below still transmits immediately. if (isBroadcast(p->to)) { - handleReceived(p, src); + deliverLocal(p, src); } // don't override if a channel was requested and no need to set it when PKI is enforced @@ -280,15 +363,6 @@ ErrorCode Router::sendLocal(meshtastic_MeshPacket *p, RxSource src) return send(p); } } -/** - * Send a packet on a suitable interface. - */ -ErrorCode Router::rawSend(meshtastic_MeshPacket *p) -{ - assert(iface); // This should have been detected already in sendLocal (or we just received a packet from outside) - return iface->send(p); -} - /** * Send a packet on a suitable interface. This routine will * later free() the packet to pool. This routine is not allowed to stall. @@ -452,18 +526,111 @@ void Router::sniffReceived(const meshtastic_MeshPacket *p, const meshtastic_Rout } #if !(MESHTASTIC_EXCLUDE_PKI) && !(MESHTASTIC_EXCLUDE_XEDDSA) +/** Size a decoded Data as the sender's signedDataFits() gate would have, with padding stripped: + * unknown fields inside Data.payload survive in payload.size and would otherwise let a forger + * inflate an unsigned broadcast past the signable budget. Returns false only if sizing failed. + * Sizing only what this build's schema decodes, so a signable type that later grows needs its + * legitimate maximum re-checked against the budget or honest unsigned broadcasts get dropped. */ +static bool canonicalSignableSize(meshtastic_Data *d, size_t *size) +{ + const pb_msgdesc_t *fields = nullptr; + switch (d->portnum) { + case meshtastic_PortNum_POSITION_APP: + fields = &meshtastic_Position_msg; + break; + case meshtastic_PortNum_TELEMETRY_APP: + fields = &meshtastic_Telemetry_msg; + break; + case meshtastic_PortNum_WAYPOINT_APP: + fields = &meshtastic_Waypoint_msg; + break; + case meshtastic_PortNum_NODEINFO_APP: + fields = &meshtastic_User_msg; + break; + default: + break; + } + + if (fields) { + // Scratch kept off the stack: these decoded structs are large for the smaller MCU targets. + // Safe as file-static state because both callers of checkXeddsaReceivePolicy hold cryptLock. + static union { + // cppcheck-suppress unusedStructMember ; written by pb_decode through &inner + meshtastic_Position position; + // cppcheck-suppress unusedStructMember ; written by pb_decode through &inner + meshtastic_Telemetry telemetry; + // cppcheck-suppress unusedStructMember ; written by pb_decode through &inner + meshtastic_Waypoint waypoint; + // cppcheck-suppress unusedStructMember ; written by pb_decode through &inner + meshtastic_User user; + } inner; + + memset(&inner, 0, sizeof(inner)); + size_t canonicalPayload; + if (pb_decode_from_bytes(d->payload.bytes, d->payload.size, fields, &inner) && + pb_get_encoded_size(&canonicalPayload, fields, &inner) && canonicalPayload <= d->payload.size) { + // Only the length matters when sizing a bytes field, so swap it in place instead of + // copying the whole Data; restored below because modules still need the real payload. + const pb_size_t prevSize = d->payload.size; + d->payload.size = (pb_size_t)canonicalPayload; + const bool sized = pb_get_encoded_size(size, &meshtastic_Data_msg, d); + d->payload.size = prevSize; + return sized; + } + } + + return pb_get_encoded_size(size, &meshtastic_Data_msg, d); +} + +enum class NodeInfoBootstrapResult { NOT_APPLICABLE, VERIFIED, INVALID }; + +static NodeInfoBootstrapResult verifyFirstContactNodeInfo(meshtastic_MeshPacket *p) +{ + if (p->decoded.portnum != meshtastic_PortNum_NODEINFO_APP) + return NodeInfoBootstrapResult::NOT_APPLICABLE; + + meshtastic_User user = meshtastic_User_init_zero; + if (!pb_decode_from_bytes(p->decoded.payload.bytes, p->decoded.payload.size, &meshtastic_User_msg, &user) || + user.public_key.size != 32 || crc32Buffer(user.public_key.bytes, user.public_key.size) != p->from || + !crypto->xeddsa_verify(user.public_key.bytes, p->from, p->id, p->decoded.portnum, p->decoded.payload.bytes, + p->decoded.payload.size, p->decoded.xeddsa_signature.bytes)) { + return NodeInfoBootstrapResult::INVALID; + } + + meshtastic_NodeInfoLite *node = nodeDB->getOrCreateMeshNode(p->from); + if (!node) + return NodeInfoBootstrapResult::INVALID; + node->public_key.size = user.public_key.size; + memcpy(node->public_key.bytes, user.public_key.bytes, user.public_key.size); + nodeInfoLiteSetBit(node, NODEINFO_BITFIELD_HAS_XEDDSA_SIGNED_MASK, true); + p->xeddsa_signed = true; + LOG_DEBUG("Verified first-contact XEdDSA NodeInfo from 0x%08x", p->from); + return NodeInfoBootstrapResult::VERIFIED; +} + bool checkXeddsaReceivePolicy(meshtastic_MeshPacket *p) { + const auto policy = config.security.packet_signature_policy; + const bool strict = policy == meshtastic_Config_SecurityConfig_PacketSignaturePolicy_PACKET_SIGNATURE_POLICY_STRICT; + const bool compatible = policy == meshtastic_Config_SecurityConfig_PacketSignaturePolicy_PACKET_SIGNATURE_POLICY_COMPATIBLE; + // Only a signature we verify below may mark this packet signed; never trust an inbound flag. p->xeddsa_signed = false; if (p->decoded.xeddsa_signature.size == XEDDSA_SIGNATURE_SIZE) { + meshtastic_NodeInfoLite_public_key_t senderKey = {0, {0}}; meshtastic_NodeInfoLite *node = nodeDB->getMeshNode(p->from); - if (node && node->public_key.size == 32) { + if (nodeDB->copyPublicKey(p->from, senderKey)) { p->xeddsa_signed = - crypto->xeddsa_verify(node->public_key.bytes, p->from, p->id, p->decoded.portnum, p->decoded.payload.bytes, + crypto->xeddsa_verify(senderKey.bytes, p->from, p->id, p->decoded.portnum, p->decoded.payload.bytes, p->decoded.payload.size, p->decoded.xeddsa_signature.bytes); if (p->xeddsa_signed) { // Learn this node as a signer, so a later unsigned signable broadcast from it is dropped + // A warm-tier key must be re-admitted before setting the signer bit; otherwise Balanced + // forgets downgrade protection as soon as the node is evicted from the hot store. + if (!node) + node = nodeDB->getOrCreateMeshNode(p->from); + if (!node) + return false; nodeInfoLiteSetBit(node, NODEINFO_BITFIELD_HAS_XEDDSA_SIGNED_MASK, true); LOG_DEBUG("Verified XEdDSA signature from 0x%08x", p->from); } else { @@ -471,7 +638,16 @@ bool checkXeddsaReceivePolicy(meshtastic_MeshPacket *p) return false; } } else { + const auto bootstrap = verifyFirstContactNodeInfo(p); + if (bootstrap == NodeInfoBootstrapResult::INVALID) { + LOG_WARN("Invalid first-contact XEdDSA NodeInfo from 0x%08x, dropping", p->from); + return false; + } + if (bootstrap == NodeInfoBootstrapResult::VERIFIED) + return true; LOG_DEBUG("No public key for 0x%08x, cannot verify XEdDSA signature", p->from); + if (strict) + return false; } } else if (p->decoded.xeddsa_signature.size != 0) { // A signature field that is neither empty nor a full 64 bytes is malformed - honest @@ -482,16 +658,26 @@ bool checkXeddsaReceivePolicy(meshtastic_MeshPacket *p) p->from); return false; } else { - // Truly unsigned (signature size 0) - only reject the class a signing node always signs: a - // non-PKI broadcast whose signed encoding would still fit the LoRa frame. Size p->decoded - // canonically so this counts the same fields the sender's signedDataFits() gate counted; - // adding XEDDSA_SIGNATURE_FIELD_BYTES to that unsigned base mirrors it exactly, whatever - // fields the Data carried. Unicast/PKI packets and broadcasts too big to carry a signature - // are never signed, so they must not be hard-failed here even for a known signer. - const meshtastic_NodeInfoLite *node = nodeDB->getMeshNode(p->from); - if (node && nodeInfoLiteHasXeddsaSigned(node) && !p->pki_encrypted && isBroadcast(p->to)) { + if (p->pki_encrypted) + return true; + if (strict) { + LOG_WARN("Dropping unsigned packet from 0x%08x in Strict signature mode", p->from); + return false; + } + if (compatible) + return true; + + // In Balanced, preserve legacy unsigned-unicast compatibility and only reject the class a + // signing node always signs: a non-PKI broadcast whose signed encoding would still fit the + // LoRa frame. Canonical sizing removes unknown protobuf fields before mirroring the + // sender-side signedDataFits() gate, so this counts the same fields that gate counted. + // Unicast packets and broadcasts too big to carry a signature are never signed, so they + // must not be hard-failed here even for a known signer (PKI already returned above). + // isKnownXeddsaSigner consults the warm tier too: a signer evicted from the hot store + // must not become impersonatable via unsigned broadcasts until it is re-heard. + if (nodeDB->isKnownXeddsaSigner(p->from) && isBroadcast(p->to)) { size_t canonicalSize; - if (!pb_get_encoded_size(&canonicalSize, &meshtastic_Data_msg, &p->decoded)) + if (!canonicalSignableSize(&p->decoded, &canonicalSize)) return true; // can't size it; never drop on a sizing failure if (canonicalSize + XEDDSA_SIGNATURE_FIELD_BYTES + MESHTASTIC_HEADER_LENGTH <= MAX_LORA_PAYLOAD_LEN) { LOG_WARN("Dropping unsigned broadcast from 0x%08x that previously signed", p->from); @@ -503,6 +689,103 @@ bool checkXeddsaReceivePolicy(meshtastic_MeshPacket *p) } #endif +RoutingAuthVerdict passesRoutingAuthGate(meshtastic_MeshPacket *p) +{ + // Routing still needs the original encrypted representation for byte-for-byte relay and for + // MQTT uplink. Authenticate a copy here; handleReceived() performs the normal in-place decode + // only after stateful routing filters have completed. + if (routingAuthCacheMatches(*p)) + return RoutingAuthVerdict::ACCEPT; + + meshtastic_MeshPacket wire = *p; + meshtastic_MeshPacket authCandidate = *p; + routingAuthEvaluations++; + if (authCandidate.which_payload_variant == meshtastic_MeshPacket_decoded_tag) { + // Already-decoded remote ingress (notably Portduino SimRadio) did not pass through a + // decryptor. Never trust serialized local authentication metadata on that boundary. + authCandidate.pki_encrypted = false; + authCandidate.public_key.size = 0; +#if !(MESHTASTIC_EXCLUDE_PKI) && !(MESHTASTIC_EXCLUDE_XEDDSA) + concurrency::LockGuard g(cryptLock); + if (!checkXeddsaReceivePolicy(&authCandidate)) { + LOG_WARN("Already-decoded packet rejected by signature policy"); + return RoutingAuthVerdict::REJECT; + } +#endif + p->xeddsa_signed = authCandidate.xeddsa_signed; + wire = *p; + storeRoutingAuthCache(wire, authCandidate); + return RoutingAuthVerdict::ACCEPT; + } + const DecodeState state = perhapsDecode(&authCandidate); + if (state == DecodeState::DECODE_POLICY_REJECT) { + LOG_WARN("Packet rejected by signature policy"); + return RoutingAuthVerdict::REJECT; + } + if (state == DecodeState::DECODE_FATAL) { + LOG_WARN("Fatal decode error, dropping packet"); + return RoutingAuthVerdict::REJECT; + } + if (state == DecodeState::DECODE_FAILURE) { + LOG_WARN("Decryptable packet failed decoding, dropping packet"); + return RoutingAuthVerdict::REJECT; + } + + // Only an explicit unknown-channel result remains eligible for opaque relay. + if (state == DecodeState::DECODE_OPAQUE) + return RoutingAuthVerdict::OPAQUE_RELAY_ONLY; + storeRoutingAuthCache(wire, authCandidate); + return RoutingAuthVerdict::ACCEPT; +} + +#if !(MESHTASTIC_EXCLUDE_PKI) +// The fallback costs three X25519 ops before the AEAD tag is checked. Budget is global because p->from is +// attacker-controlled; successful runs refund, and their key is then persisted for the fast path. +#define ADMIN_KEY_FALLBACK_BURST 8 +#define ADMIN_KEY_FALLBACK_REFILL_MS 250 + +static uint32_t adminKeyFallbackTokens = ADMIN_KEY_FALLBACK_BURST; +static uint32_t adminKeyFallbackRefillMs = 0; + +static bool adminKeyFallbackAllowed() +{ + bool haveAdminKey = false; + for (int i = 0; i < 3; i++) { + if (config.security.admin_key[i].size == 32) { + haveAdminKey = true; + break; + } + } + if (!haveAdminKey) + return false; // nothing to try, so do not spend a token + + uint32_t now = millis(); + if (adminKeyFallbackRefillMs == 0) + adminKeyFallbackRefillMs = now; + uint32_t elapsed = now - adminKeyFallbackRefillMs; + if (elapsed >= ADMIN_KEY_FALLBACK_REFILL_MS) { + uint32_t refill = elapsed / ADMIN_KEY_FALLBACK_REFILL_MS; + adminKeyFallbackRefillMs += refill * ADMIN_KEY_FALLBACK_REFILL_MS; + if (refill >= ADMIN_KEY_FALLBACK_BURST - adminKeyFallbackTokens) + adminKeyFallbackTokens = ADMIN_KEY_FALLBACK_BURST; + else + adminKeyFallbackTokens += refill; + } + + if (adminKeyFallbackTokens == 0) + return false; + + adminKeyFallbackTokens--; + return true; +} + +static void adminKeyFallbackRefund() +{ + if (adminKeyFallbackTokens < ADMIN_KEY_FALLBACK_BURST) + adminKeyFallbackTokens++; +} +#endif + DecodeState perhapsDecode(meshtastic_MeshPacket *p) { concurrency::LockGuard g(cryptLock); @@ -516,39 +799,67 @@ DecodeState perhapsDecode(meshtastic_MeshPacket *p) if (p->which_payload_variant == meshtastic_MeshPacket_decoded_tag) return DecodeState::DECODE_SUCCESS; // If packet was already decoded just return + // Authentication metadata is local-only. Re-establish it below only after successful PKI decryption. + p->pki_encrypted = false; + p->public_key.size = 0; + size_t rawSize = p->encrypted.size; if (rawSize > sizeof(bytes)) { LOG_ERROR("Packet too large to attempt decryption! (rawSize=%d > 256)", rawSize); return DecodeState::DECODE_FATAL; } bool decrypted = false; + bool pkiAttempted = false; + bool licensedPkiCandidate = false; + bool matchedChannel = false; ChannelIndex chIndex = 0; #if !(MESHTASTIC_EXCLUDE_PKI) - // Resolve the sender's public key: prefer the one stored in NodeDB (hot store or warm tier), else - // fall back to a not-yet-committed key held during an in-progress key-verification handshake. - meshtastic_NodeInfoLite_public_key_t remotePublic = {0, {0}}; - bool haveRemoteKey = nodeDB->copyPublicKey(p->from, remotePublic) || crypto->getPendingPublicKey(p->from, remotePublic); - meshtastic_NodeInfoLite *ourNode = nullptr; - if (p->channel == 0 && isToUs(p) && p->to > 0 && !isBroadcast(p->to) && rawSize > MESHTASTIC_PKC_OVERHEAD && - (ourNode = nodeDB->getMeshNode(p->to)) != nullptr && ourNode->public_key.size > 0) { + const bool pkiCandidate = p->channel == 0 && isToUs(p) && p->to > 0 && !isBroadcast(p->to) && + rawSize > MESHTASTIC_PKC_OVERHEAD && (ourNode = nodeDB->getMeshNode(p->to)) != nullptr && + ourNode->public_key.size > 0; + if (pkiCandidate && owner.is_licensed) { + licensedPkiCandidate = true; + } else if (pkiCandidate) { + pkiAttempted = true; + LOG_DEBUG("Attempt PKI decryption"); + // Resolve the sender's key only for actual PKI-decrypt candidates, not every encrypted channel + // packet: copyPublicKeyForDecrypt() can fall through to a linear scan of TrafficManagement's large + // NodeInfo cache. It returns authoritative keys (hot/warm), or a cold-tier cache key only when it is + // signer-proven - an unverified TOFU cache key must not back authenticated (pki_encrypted, p->from) + // DM attribution. + meshtastic_NodeInfoLite_public_key_t remotePublic = {0, {0}}; + bool haveRemoteKey = nodeDB->copyPublicKeyForDecrypt(p->from, remotePublic); + // A pending key is an unverified identity claim supplied by whoever opened the handshake, so it is + // accepted only for the exchange itself (checked after decode). perhapsEncode applies the same rule. + bool havePendingKey = false; + if (!haveRemoteKey) { + havePendingKey = crypto->getPendingPublicKey(p->from, remotePublic); + haveRemoteKey = havePendingKey; + } // Try the sender's known key first, then each configured admin key so an authorized admin can // reach a node that has not yet learned their key. AES-CCM AEAD rejects wrong candidates. bool viaAdminKey = false; + bool viaPendingKey = false; if (haveRemoteKey && crypto->decryptCurve25519(p->from, remotePublic, p->id, rawSize, p->encrypted.bytes, bytes)) { decrypted = true; + viaPendingKey = havePendingKey; } - for (int i = 0; i < 3 && !decrypted; i++) { - if (config.security.admin_key[i].size != 32) - continue; - remotePublic.size = 32; - memcpy(remotePublic.bytes, config.security.admin_key[i].bytes, 32); + if (!decrypted && adminKeyFallbackAllowed()) { + for (int i = 0; i < 3 && !decrypted; i++) { + if (config.security.admin_key[i].size != 32) + continue; + remotePublic.size = 32; + memcpy(remotePublic.bytes, config.security.admin_key[i].bytes, 32); - if (crypto->decryptCurve25519(p->from, remotePublic, p->id, rawSize, p->encrypted.bytes, bytes)) { - decrypted = true; - viaAdminKey = true; - break; // stop after first successful decryption + if (crypto->decryptCurve25519(p->from, remotePublic, p->id, rawSize, p->encrypted.bytes, bytes)) { + decrypted = true; + viaAdminKey = true; + break; // stop after first successful decryption + } } + if (decrypted) + adminKeyFallbackRefund(); } if (decrypted) { LOG_INFO("PKI Decryption worked!"); @@ -557,6 +868,12 @@ DecodeState perhapsDecode(meshtastic_MeshPacket *p) size_t payloadSize = rawSize - MESHTASTIC_PKC_OVERHEAD; if (pb_decode_from_bytes(bytes, payloadSize, &meshtastic_Data_msg, &decodedtmp) && decodedtmp.portnum != meshtastic_PortNum_UNKNOWN_APP) { + if (viaPendingKey && decodedtmp.portnum != meshtastic_PortNum_KEY_VERIFICATION_APP) { + // The pending key only proves the handshake initiator holds it, not that they are + // p->from. Beyond the exchange it would let them send DMs that look authenticated. + LOG_WARN("Refusing pending-key decrypt of port %u from 0x%08x", (unsigned)decodedtmp.portnum, p->from); + return DecodeState::DECODE_FAILURE; + } decrypted = true; rawSize = payloadSize; // commit the overhead subtraction only on full success LOG_INFO("Packet decrypted using PKI!"); @@ -567,10 +884,12 @@ DecodeState perhapsDecode(meshtastic_MeshPacket *p) p->which_payload_variant = meshtastic_MeshPacket_decoded_tag; // change type to decoded if (viaAdminKey) { // Persist the admin key for the sender so future packets take the fast path and we can - // PKI-reply; p->from is bound into the AEAD nonce, so the trusted admin authenticated it. - meshtastic_NodeInfoLite *fromNode = nodeDB->getOrCreateMeshNode(p->from); - if (fromNode != nullptr) - fromNode->public_key = remotePublic; + // PKI-reply; p->from is bound into the AEAD nonce, so the trusted admin authenticated + // it. commitRemoteKey is the bare-key commit primitive: it bypasses updateUser's + // User-payload path deliberately and handles the TrafficManagement write-through. + // AdminChannelProven = possession shown to the admin channel, not via an XEdDSA + // NodeInfo signature, so the key stays TOFU-grade for signing purposes. + nodeDB->commitRemoteKey(p->from, remotePublic.bytes, NodeDB::KeyCommitTrust::AdminChannelProven); } } else { // AEAD already authenticated this ciphertext, so no other candidate could decode it - @@ -588,6 +907,7 @@ DecodeState perhapsDecode(meshtastic_MeshPacket *p) for (chIndex = 0; chIndex < channels.getNumChannels(); chIndex++) { // Try to use this hash/channel pair if (channels.decryptForHash(chIndex, p->channel)) { + matchedChannel = true; // we have to copy into a scratch buffer, because these bytes are a union with the decoded protobuf. Create a // fresh copy for each decrypt attempt. memcpy(bytes, p->encrypted.bytes, rawSize); @@ -623,10 +943,9 @@ DecodeState perhapsDecode(meshtastic_MeshPacket *p) p->channel = chIndex; // change to store the index instead of the hash #if !(MESHTASTIC_EXCLUDE_PKI) && !(MESHTASTIC_EXCLUDE_XEDDSA) - // Runs before the bitfield merge below: that merge can set want_response, adding wire bytes - // the sender never encoded and skewing the policy's sizing of p->decoded. + // Run before merging local-only bitfield state into the decoded Data. if (!checkXeddsaReceivePolicy(p)) - return DecodeState::DECODE_FAILURE; + return DecodeState::DECODE_POLICY_REJECT; #endif if (p->decoded.has_bitfield) @@ -684,7 +1003,8 @@ DecodeState perhapsDecode(meshtastic_MeshPacket *p) return DecodeState::DECODE_SUCCESS; } else { LOG_WARN("No suitable channel found for decoding, hash was 0x%x!", p->channel); - return DecodeState::DECODE_FAILURE; + return (matchedChannel || pkiAttempted || licensedPkiCandidate) ? DecodeState::DECODE_FAILURE + : DecodeState::DECODE_OPAQUE; } } @@ -702,6 +1022,34 @@ static bool signedDataFits(meshtastic_Data *d) } #endif +#if !(MESHTASTIC_EXCLUDE_PKI) +bool wouldEncryptWithPKC(const meshtastic_MeshPacket *p, ChannelIndex chIndex, bool haveDestKey) +{ + // First, only PKC encrypt packets we are originating + return isFromUs(p) && +#if ARCH_PORTDUINO + // Sim radio via the cli flag skips PKC + !portduino_config.force_simradio && +#endif + // Don't use PKC with Ham mode + !owner.is_licensed && + // Don't use PKC on 'serial' or 'gpio' channels unless explicitly requested + !(p->pki_encrypted != true && (strcasecmp(channels.getName(chIndex), Channels::serialChannel) == 0 || + strcasecmp(channels.getName(chIndex), Channels::gpioChannel) == 0)) && + // Check for valid keys and single node destination + config.security.private_key.size == 32 && !isBroadcast(p->to) && + // Some portnums either make no sense to send with PKC + p->decoded.portnum != meshtastic_PortNum_TRACEROUTE_APP && p->decoded.portnum != meshtastic_PortNum_NODEINFO_APP && + p->decoded.portnum != meshtastic_PortNum_ROUTING_APP && p->decoded.portnum != meshtastic_PortNum_POSITION_APP && + // We allow Key Verification messages to be sent without a known destination key, since the point of those messages is + // to exchange keys. The first exchange (no usable key yet) falls through to channel encryption; the follow-on packet + // uses the pending key resolved into haveDestKey/destKey above. + // Though possible the first packet each direction should go non-pkc + // to handle the case where the remote node has our key, but we don't have theirs. + !(p->decoded.portnum == meshtastic_PortNum_KEY_VERIFICATION_APP && !haveDestKey); +} +#endif + /** Return 0 for success or a Routing_Error code for failure */ meshtastic_Routing_Error perhapsEncode(meshtastic_MeshPacket *p) @@ -722,12 +1070,12 @@ meshtastic_Routing_Error perhapsEncode(meshtastic_MeshPacket *p) // verification at every XEdDSA-enabled receiver that knows our key. p->decoded.xeddsa_signature.size = 0; #if !(MESHTASTIC_EXCLUDE_PKI) && !(MESHTASTIC_EXCLUDE_XEDDSA) - // Sign broadcast packets when the Data still fits a LoRa frame with the signature - // attached. This must be the exact encoded-size criterion, not a payload-size - // heuristic: a heuristic band where we sign-then-fail-TOO_LARGE breaks packets that + // Licensed packets stay plaintext, so sign both broadcasts and unicasts. Normal mode + // continues to sign broadcasts only. Use the exact encoded size: a payload-size heuristic + // where we sign-then-fail-TOO_LARGE breaks packets that // were deliverable unsigned, and perhapsDecode() applies the mirror-image rule when // deciding whether an unsigned broadcast from a known signer is a downgrade. - if (!p->pki_encrypted && isBroadcast(p->to) && signedDataFits(&p->decoded)) { + if (!p->pki_encrypted && (owner.is_licensed || isBroadcast(p->to)) && signedDataFits(&p->decoded)) { if (crypto->xeddsa_sign(p->from, p->id, p->decoded.portnum, p->decoded.payload.bytes, p->decoded.payload.size, p->decoded.xeddsa_signature.bytes)) { p->decoded.xeddsa_signature.size = XEDDSA_SIGNATURE_SIZE; @@ -795,28 +1143,7 @@ meshtastic_Routing_Error perhapsEncode(meshtastic_MeshPacket *p) } // We may want to retool things so we can send a PKC packet when the client specifies a key and nodenum, even if the node // is not in the local nodedb - // First, only PKC encrypt packets we are originating - if (isFromUs(p) && -#if ARCH_PORTDUINO - // Sim radio via the cli flag skips PKC - !portduino_config.force_simradio && -#endif - // Don't use PKC with Ham mode - !owner.is_licensed && - // Don't use PKC on 'serial' or 'gpio' channels unless explicitly requested - !(p->pki_encrypted != true && (strcasecmp(channels.getName(chIndex), Channels::serialChannel) == 0 || - strcasecmp(channels.getName(chIndex), Channels::gpioChannel) == 0)) && - // Check for valid keys and single node destination - config.security.private_key.size == 32 && !isBroadcast(p->to) && - // Some portnums either make no sense to send with PKC - p->decoded.portnum != meshtastic_PortNum_TRACEROUTE_APP && p->decoded.portnum != meshtastic_PortNum_NODEINFO_APP && - p->decoded.portnum != meshtastic_PortNum_ROUTING_APP && p->decoded.portnum != meshtastic_PortNum_POSITION_APP && - // We allow Key Verification messages to be sent without a known destination key, since the point of those messages is - // to exchange keys. The first exchange (no usable key yet) falls through to channel encryption; the follow-on packet - // uses the pending key resolved into haveDestKey/destKey above. - // Though possible the first packet each direction should go non-pkc - // to handle the case where the remote node has our key, but we don't have theirs. - !(p->decoded.portnum == meshtastic_PortNum_KEY_VERIFICATION_APP && !haveDestKey)) { + if (wouldEncryptWithPKC(p, chIndex, haveDestKey)) { LOG_DEBUG("Use PKI!"); if (numbytes + MESHTASTIC_HEADER_LENGTH + MESHTASTIC_PKC_OVERHEAD > MAX_LORA_PAYLOAD_LEN) return meshtastic_Routing_Error_TOO_LARGE; @@ -881,31 +1208,123 @@ NodeNum Router::getNodeNum() return nodeDB->getNodeNum(); } +bool Router::enqueueDeferredLocal(meshtastic_MeshPacket *p, RxSource src) +{ + if (deferredLocalCount >= deferredLocalCapacity) + return false; + uint8_t tail = (deferredLocalHead + deferredLocalCount) % deferredLocalCapacity; + deferredLocalQueue[tail].p = p; + deferredLocalQueue[tail].src = src; + deferredLocalCount++; + return true; +} + +bool Router::dequeueDeferredLocal(DeferredLocal &out) +{ + if (deferredLocalCount == 0) + return false; + out = deferredLocalQueue[deferredLocalHead]; + deferredLocalHead = (deferredLocalHead + 1) % deferredLocalCapacity; + deferredLocalCount--; + return true; +} + +void Router::deliverLocal(meshtastic_MeshPacket *p, RxSource src) +{ + // Top level: handle synchronously, exactly as before the depth guard existed. + if (handleDepth == 0) { + handleReceived(p, src); + return; + } + + // Nested: a module sent this from inside callModules(). Defer a copy so the outermost + // handleReceived() drains it once the current dispatch unwinds, instead of stacking another + // handleReceived() frame on top of the module handler (nRF52 stack overflow on config save). + meshtastic_MeshPacket *copy = packetPool.allocCopy(*p); + if (copy && enqueueDeferredLocal(copy, src)) + return; + + // Pool exhausted or queue full: drop the deferral. Leak-free and degraded but safe - the + // packet still followed its normal non-loopback path (SHOULD_RELEASE, or the TX path for a + // broadcast). Mirrors sendToPhone()'s degrade-on-exhaustion behavior. + if (copy) + packetPool.release(copy); + LOG_WARN("Deferred local queue full/alloc failed, dropping loopback of 0x%08x", p->id); +#ifdef PIO_UNIT_TESTING + deferredLocalDropped++; +#endif +} + /** * Handle any packet that is received by an interface on this node. * Note: some packets may merely being passed through this node and will be forwarded elsewhere. */ void Router::handleReceived(meshtastic_MeshPacket *p, RxSource src) +{ + handleDepth++; +#ifdef PIO_UNIT_TESTING + if (handleDepth > maxHandleDepthObserved) + maxHandleDepthObserved = handleDepth; +#endif + + dispatchReceived(p, src); + + // Only the outermost frame drains. Deferred packets were produced by modules sending from + // inside dispatchReceived()'s callModules(); process them here, after the triggering frame has + // unwound, so a second handleReceived() never sits on top of a module handler. handleDepth + // stays >= 1 through the drain, so a drained packet whose own modules send more loopback + // packets enqueues them for this same loop rather than recursing: the stack stays flat and + // processing is breadth-first. + if (handleDepth == 1) { + DeferredLocal d; + while (dequeueDeferredLocal(d)) { + dispatchReceived(d.p, d.src); + packetPool.release(d.p); + } + } + + handleDepth--; +} + +void Router::dispatchReceived(meshtastic_MeshPacket *p, RxSource src) { bool skipHandle = false; - // Also, we should set the time from the ISR and it should have msec level resolution - p->rx_time = getValidTime(RTCQualityFromNet); // store the arrival timestamp for the phone // Store a copy of the encrypted packet for MQTT. - // Local, not a class member: handleReceived re-enters itself when a module - // reply broadcast goes through MeshService::sendToMesh -> Router::sendLocal, - // and a member would be silently overwritten without release on the inner - // call. Each invocation now owns its own copy (issue #9632, #10101, #8729). + // Kept as a local (not a class member) so each dispatch owns its own copy. A shared member was + // historically overwritten without release when a module's reply re-entered this path through + // MeshService::sendToMesh -> Router::sendLocal (issues #9632, #10101, #8729). Nested local + // sends are now deferred rather than synchronously re-entrant (see the drain in + // handleReceived()), so this no longer strictly needs to be a local, but it is kept per-call. DEBUG_HEAP_BEFORE; meshtastic_MeshPacket *p_encrypted = packetPool.allocCopy(*p); DEBUG_HEAP_AFTER("Router::handleReceived", p_encrypted); + // Consume the decoded/authenticated handoff after preserving the exact encrypted packet and + // before mutating any packet fields that participate in the exact cache match. + if (src == RX_SRC_RADIO) + applyRoutingAuthCache(p); + + // Also, we should set the time from the ISR and it should have msec level resolution. + // Keep the decoded working packet and encrypted MQTT copy on the same local arrival timestamp. + const uint32_t rxTime = getValidTime(RTCQualityFromNet); + p->rx_time = rxTime; + if (p_encrypted) + p_encrypted->rx_time = rxTime; + // Take those raw bytes and convert them back into a well structured protobuf we can understand auto decodedState = perhapsDecode(p); - if (decodedState == DecodeState::DECODE_FATAL) { + if (decodedState == DecodeState::DECODE_FATAL || decodedState == DecodeState::DECODE_POLICY_REJECT || + decodedState == DecodeState::DECODE_FAILURE) { // Fatal decoding error, we can't do anything with this packet - LOG_WARN("Fatal decode error, dropping packet"); - cancelSending(p->from, p->id); + LOG_WARN(decodedState == DecodeState::DECODE_POLICY_REJECT + ? "Packet rejected by signature policy" + : (decodedState == DecodeState::DECODE_FATAL ? "Fatal decode error, dropping packet" + : "Decryptable packet failed decoding, dropping packet")); + // A policy rejection is attacker-controlled input and must not cancel a valid pending + // transmission with the same (from, id). Preserve the pre-existing fatal-decode behavior. + if (decodedState == DecodeState::DECODE_FATAL) + cancelSending(p->from, p->id); skipHandle = true; } else if (decodedState == DecodeState::DECODE_SUCCESS) { // parsing was successful, queue for our recipient @@ -981,7 +1400,7 @@ void Router::handleReceived(meshtastic_MeshPacket *p, RxSource src) } else { // Mark as pki_encrypted if it is not yet decoded and MQTT encryption is also enabled, hash matches and it's a DM not // to us (because we would be able to decrypt it) - if (decodedState == DecodeState::DECODE_FAILURE && moduleConfig.mqtt.encryption_enabled && p->channel == 0x00 && + if (decodedState == DecodeState::DECODE_OPAQUE && moduleConfig.mqtt.encryption_enabled && p->channel == 0x00 && !isBroadcast(p->to) && !isToUs(p)) p_encrypted->pki_encrypted = true; // After potentially altering it, publish received message to MQTT if we're not the original transmitter of the packet @@ -1030,6 +1449,7 @@ void Router::perhapsHandleReceived(meshtastic_MeshPacket *p) #endif // assert(radioConfig.has_preferences); if (is_in_repeated(config.lora.ignore_incoming, p->from)) { + clearRoutingAuthCache(); LOG_DEBUG("Ignore msg, 0x%08x is in our ignore list", p->from); packetPool.release(p); return; @@ -1037,30 +1457,49 @@ void Router::perhapsHandleReceived(meshtastic_MeshPacket *p) meshtastic_NodeInfoLite const *node = nodeDB->getMeshNode(p->from); if (nodeInfoLiteIsIgnored(node)) { + clearRoutingAuthCache(); LOG_DEBUG("Ignore msg, 0x%08x is ignored", p->from); packetPool.release(p); return; } if (p->from == NODENUM_BROADCAST) { + clearRoutingAuthCache(); LOG_DEBUG("Ignore msg from broadcast address"); packetPool.release(p); return; } if (config.lora.ignore_mqtt && p->via_mqtt) { + clearRoutingAuthCache(); LOG_DEBUG("Msg came in via MQTT from 0x%08x", p->from); packetPool.release(p); return; } if (shouldDropPacketForPreHop(*p)) { + clearRoutingAuthCache(); logHopStartDrop(*p, "pre-hop drop"); packetPool.release(p); return; } + // Decrypt and authenticate before Reliable/Flooding/NextHop filters can update retry + // timers, packet history, implicit ACK state, cancellation, or relay queues. A packet for + // an unknown channel passes as opaque traffic and retains the existing relay behavior. + const auto authVerdict = passesRoutingAuthGate(p); + if (authVerdict == RoutingAuthVerdict::REJECT) { + packetPool.release(p); + return; + } + if (authVerdict == RoutingAuthVerdict::OPAQUE_RELAY_ONLY) { + relayOpaquePacket(p); + packetPool.release(p); + return; + } + if (shouldFilterReceived(p)) { + clearRoutingAuthCache(); LOG_DEBUG("Incoming msg was filtered from 0x%08x", p->from); packetPool.release(p); return; diff --git a/src/mesh/Router.h b/src/mesh/Router.h index d5d4b76ad..4a6356cb5 100644 --- a/src/mesh/Router.h +++ b/src/mesh/Router.h @@ -94,7 +94,6 @@ class Router : protected concurrency::OSThread, protected PacketHistory * NOTE: This method will free the provided packet (even if we return an error code) */ virtual ErrorCode send(meshtastic_MeshPacket *p); - virtual ErrorCode rawSend(meshtastic_MeshPacket *p); /* Statistics for the amount of duplicate received packets and the amount of times we cancel a relay because someone did it before us */ @@ -113,6 +112,9 @@ class Router : protected concurrency::OSThread, protected PacketHistory */ virtual bool shouldFilterReceived(const meshtastic_MeshPacket *p) { return false; } + /** Relay an opaque packet without admitting it to local routing/history state. */ + virtual bool relayOpaquePacket(const meshtastic_MeshPacket *) { return false; } + /** * Determine if hop_limit should be decremented for a relay operation. * Returns false (preserve hop_limit) only if all conditions are met: @@ -158,11 +160,66 @@ class Router : protected concurrency::OSThread, protected PacketHistory */ void handleReceived(meshtastic_MeshPacket *p, RxSource src = RX_SRC_RADIO); + /** + * The body of handleReceived(): decode, run modules, publish to MQTT. Split out so the + * depth-guarded drain in handleReceived() can process a deferred packet without re-entering + * the drain (and without touching handleDepth) - keeping the stack flat. + */ + void dispatchReceived(meshtastic_MeshPacket *p, RxSource src); + + /** + * Route a packet addressed to us (or a local broadcast we loop back) into handleReceived(). + * Called synchronously at the top level, but if a module sends this from inside callModules() + * (handleDepth > 0) the packet is copied into the deferred queue instead, so we never stack a + * second handleReceived() on top of a module handler - that nesting is what overflows the + * nRF52 task stack on a config save. Does not consume p; the caller's existing free path is + * unchanged. + */ + void deliverLocal(meshtastic_MeshPacket *p, RxSource src); + + /// Depth of handleReceived() frames currently on the stack. >0 means a module is dispatching, + /// so a locally-sent loopback packet must be deferred rather than handled synchronously. + uint8_t handleDepth = 0; + + /// A local loopback packet whose handleReceived() was deferred because it was produced from + /// inside callModules(). The queue owns the packet; its RxSource travels with it so the drain + /// dispatches it with the origin the sender intended (RX_SRC_LOCAL stays local). + struct DeferredLocal { + meshtastic_MeshPacket *p; + RxSource src; + }; + + /// Fixed, small ring buffer of deferred local packets. A config save fans out only a few + /// loopback packets (a self-addressed reply plus a nodeinfo/config broadcast or two), so four + /// slots cover the realistic nesting. On overflow the deferral is dropped (the packet still + /// followed its normal non-loopback path) rather than blocking or growing the heap. + static constexpr uint8_t deferredLocalCapacity = 4; + DeferredLocal deferredLocalQueue[deferredLocalCapacity]; + uint8_t deferredLocalHead = 0; // index of the oldest queued entry + uint8_t deferredLocalCount = 0; // entries currently queued + + /// Queue a deferred local packet. Returns false (and queues nothing) when full. + bool enqueueDeferredLocal(meshtastic_MeshPacket *p, RxSource src); + /// Pop the oldest deferred local packet into out. Returns false when empty. + bool dequeueDeferredLocal(DeferredLocal &out); + /** Frees the provided packet, and generates a NAK indicating the specifed error while sending */ void abortSendAndNak(meshtastic_Routing_Error err, meshtastic_MeshPacket *p); + +#ifdef PIO_UNIT_TESTING + public: + /// High-water mark of handleDepth across this Router's life. The deferral must keep it at 1: + /// a nested local send may never re-enter handleReceived() synchronously. + uint8_t maxHandleDepthObserved = 0; + /// Count of deferrals dropped because the queue was full or a copy could not be allocated. + uint32_t deferredLocalDropped = 0; + /// Number of deferred local packets currently queued. + uint8_t deferredLocalPending() const { return deferredLocalCount; } +#endif }; -enum DecodeState { DECODE_SUCCESS, DECODE_FAILURE, DECODE_FATAL }; +enum DecodeState { DECODE_SUCCESS, DECODE_FAILURE, DECODE_OPAQUE, DECODE_FATAL, DECODE_POLICY_REJECT }; +enum class RoutingAuthVerdict { ACCEPT, OPAQUE_RELAY_ONLY, REJECT }; /** FIXME - move this into a mesh packet class * Remove any encryption and decode the protobufs inside this packet (if necessary). @@ -171,29 +228,35 @@ enum DecodeState { DECODE_SUCCESS, DECODE_FAILURE, DECODE_FATAL }; */ DecodeState perhapsDecode(meshtastic_MeshPacket *p); +/** Apply receive authentication before routing state mutation; unknown-channel packets may remain opaque relay-only. */ +RoutingAuthVerdict passesRoutingAuthGate(meshtastic_MeshPacket *p); +#ifdef PIO_UNIT_TESTING +uint32_t routingAuthEvaluationCount(); +void resetRoutingAuthEvaluationCount(); +#endif + /** Return 0 for success or a Routing_Error code for failure */ meshtastic_Routing_Error perhapsEncode(meshtastic_MeshPacket *p); #if !(MESHTASTIC_EXCLUDE_PKI) && !(MESHTASTIC_EXCLUDE_XEDDSA) -/** XEdDSA receive-side signature policy. When the packet carries a 64-byte signature *and* the - * sender's public key is known, verify it: on success learn the sender's signer bit, on failure - * drop. If the key is unknown the signature is left unverified and the packet passes. A signature - * of any other non-zero length is treated as malformed and dropped. For unsigned packets, enforce - * downgrade protection: drop a non-PKI broadcast from a known signer whose signed encoding would - * still fit a LoRa frame (unicast, PKI, and oversized broadcasts always pass). - * - * The fit test sizes p->decoded with the real encoder, so it measures the fields the sender - * encoded rather than any raw wire length. - * - * The caller MUST hold cryptLock: verification runs through the shared CryptoEngine key cache. - * (perhapsDecode already holds it; other call sites must take it themselves.) - * - * @return false if the packet must be dropped. - */ +/** Enforce the configured XEdDSA receive policy. The caller must hold cryptLock. + * Returns false when the packet must be dropped. */ bool checkXeddsaReceivePolicy(meshtastic_MeshPacket *p); #endif +#if !(MESHTASTIC_EXCLUDE_PKI) +/** + * Would perhapsEncode() PKC-encrypt this outgoing packet? Callers that must know the encryption a + * packet will get before it is encoded (e.g. pinning a peer key at request time) have to ask this + * rather than inspect p, whose pki_encrypted/public_key fields are only populated on the RX path. + * + * @param chIndex the channel index p carries before encoding rewrites it to a hash. + * @param haveDestKey whether a public key for p->to was resolvable. + */ +bool wouldEncryptWithPKC(const meshtastic_MeshPacket *p, ChannelIndex chIndex, bool haveDestKey); +#endif + extern Router *router; /// Generate a unique packet id diff --git a/src/mesh/SinglePortModule.h b/src/mesh/SinglePortModule.h index e43de09d1..d20a9b7e4 100644 --- a/src/mesh/SinglePortModule.h +++ b/src/mesh/SinglePortModule.h @@ -8,14 +8,11 @@ */ class SinglePortModule : public MeshModule { - protected: - meshtastic_PortNum ourPortNum; - public: /** Constructor * name is for debugging output */ - SinglePortModule(const char *_name, meshtastic_PortNum _ourPortNum) : MeshModule(_name), ourPortNum(_ourPortNum) {} + SinglePortModule(const char *_name, meshtastic_PortNum _ourPortNum) : MeshModule(_name, _ourPortNum) {} protected: /** @@ -32,8 +29,10 @@ class SinglePortModule : public MeshModule { // Update our local node info with our position (even if we don't decide to update anyone else) meshtastic_MeshPacket *p = router->allocForSending(); + if (!p) + return nullptr; p->decoded.portnum = ourPortNum; return p; } -}; \ No newline at end of file +}; diff --git a/src/mesh/WarmNodeStore.cpp b/src/mesh/WarmNodeStore.cpp index cce88e6b0..d36cd67db 100644 --- a/src/mesh/WarmNodeStore.cpp +++ b/src/mesh/WarmNodeStore.cpp @@ -161,6 +161,12 @@ bool WarmNodeStore::lookupMeta(NodeNum num, uint8_t &role, uint8_t &protectedCat return true; } +bool WarmNodeStore::isVerifiedSigner(NodeNum num) const +{ + const WarmNodeEntry *e = find(num); + return e && warmSignerOf(*e); +} + bool WarmNodeStore::take(NodeNum num, WarmNodeEntry &out) { WarmNodeEntry *e = find(num); diff --git a/src/mesh/WarmNodeStore.h b/src/mesh/WarmNodeStore.h index 96bfd3ec0..26c6f3f0c 100644 --- a/src/mesh/WarmNodeStore.h +++ b/src/mesh/WarmNodeStore.h @@ -63,7 +63,7 @@ static constexpr uint32_t WARM_SIGNER_MASK = 0x01u; enum class WarmFormat : uint8_t { Current, V2, V1 }; // Protected category cached alongside role so consumers needn't re-derive the mapping. -enum class WarmProtected : uint8_t { None = 0, Role = 1, Flag = 2 }; +enum class WarmProtected : uint8_t { None = 0, Role = 1, Flag = 2, XeddsaSigner = 3 }; inline uint32_t warmPackLastHeard(uint32_t lastHeard, uint8_t role, uint8_t prot, bool signer) { @@ -119,6 +119,10 @@ class WarmNodeStore /// @return false if the node is not in the warm tier. bool lookupMeta(NodeNum num, uint8_t &role, uint8_t &protectedCat) const; + /// True if the warm tier holds this node with its signer bit set (an XEdDSA signature + /// was verified from it before eviction). + bool isVerifiedSigner(NodeNum num) const; + /// Find and remove an entry (used when the node is re-admitted to the hot store). bool take(NodeNum num, WarmNodeEntry &out); @@ -131,6 +135,15 @@ class WarmNodeStore size_t count() const; size_t capacity() const { return entries ? WARM_NODE_COUNT : 0; } + /// Slot-indexed read for whole-tier reconciliation: the entry in slot i, or nullptr + /// when the slot is empty or i >= capacity(). + const WarmNodeEntry *entryAt(size_t i) const + { + if (!entries || i >= WARM_NODE_COUNT || entries[i].num == 0) + return nullptr; + return &entries[i]; + } + #if MESHTASTIC_NODEDB_MIGRATION_VERBOSE /// Debug: dump every live warm entry (num / last_heard / has-key) to the /// console. Compiled out unless MESHTASTIC_NODEDB_MIGRATION_VERBOSE. diff --git a/src/mesh/generated/meshtastic/config.pb.cpp b/src/mesh/generated/meshtastic/config.pb.cpp index c554ca43c..d89388744 100644 --- a/src/mesh/generated/meshtastic/config.pb.cpp +++ b/src/mesh/generated/meshtastic/config.pb.cpp @@ -69,6 +69,8 @@ PB_BIND(meshtastic_Config_SessionkeyConfig, meshtastic_Config_SessionkeyConfig, + + diff --git a/src/mesh/generated/meshtastic/config.pb.h b/src/mesh/generated/meshtastic/config.pb.h index 0778a9bc5..8423f54ba 100644 --- a/src/mesh/generated/meshtastic/config.pb.h +++ b/src/mesh/generated/meshtastic/config.pb.h @@ -399,6 +399,19 @@ typedef enum _meshtastic_Config_BluetoothConfig_PairingMode { meshtastic_Config_BluetoothConfig_PairingMode_NO_PIN = 2 } meshtastic_Config_BluetoothConfig_PairingMode; +/* Controls how the device authenticates remotely received mesh packets. */ +typedef enum _meshtastic_Config_SecurityConfig_PacketSignaturePolicy { + /* Accept unsigned packets for maximum compatibility while still rejecting malformed or invalid signatures. + This is the default to avoid legacy nodes dropping signed packets during rebroadcast. */ + meshtastic_Config_SecurityConfig_PacketSignaturePolicy_PACKET_SIGNATURE_POLICY_COMPATIBLE = 0, + /* Prefer authenticated packets while retaining compatibility with unsigned packets from nodes not known to sign. + Rejects unsigned, signable broadcasts from nodes that have previously signed. */ + meshtastic_Config_SecurityConfig_PacketSignaturePolicy_PACKET_SIGNATURE_POLICY_BALANCED = 1, + /* Accept only packets authenticated by a verified XEdDSA signature or successful PKI decryption. + Unsigned, malformed, invalid, or unverifiable packets are ignored. */ + meshtastic_Config_SecurityConfig_PacketSignaturePolicy_PACKET_SIGNATURE_POLICY_STRICT = 2 +} meshtastic_Config_SecurityConfig_PacketSignaturePolicy; + /* Struct definitions */ /* Configuration */ typedef struct _meshtastic_Config_DeviceConfig { @@ -689,6 +702,8 @@ typedef struct _meshtastic_Config_SecurityConfig { bool debug_log_api_enabled; /* Allow incoming device control over the insecure legacy admin channel. */ bool admin_channel_enabled; + /* Determines the packet signature policy applied to remotely received mesh packets. */ + meshtastic_Config_SecurityConfig_PacketSignaturePolicy packet_signature_policy; } meshtastic_Config_SecurityConfig; /* Blank config request, strictly for getting the session key */ @@ -782,6 +797,10 @@ extern "C" { #define _meshtastic_Config_BluetoothConfig_PairingMode_MAX meshtastic_Config_BluetoothConfig_PairingMode_NO_PIN #define _meshtastic_Config_BluetoothConfig_PairingMode_ARRAYSIZE ((meshtastic_Config_BluetoothConfig_PairingMode)(meshtastic_Config_BluetoothConfig_PairingMode_NO_PIN+1)) +#define _meshtastic_Config_SecurityConfig_PacketSignaturePolicy_MIN meshtastic_Config_SecurityConfig_PacketSignaturePolicy_PACKET_SIGNATURE_POLICY_COMPATIBLE +#define _meshtastic_Config_SecurityConfig_PacketSignaturePolicy_MAX meshtastic_Config_SecurityConfig_PacketSignaturePolicy_PACKET_SIGNATURE_POLICY_STRICT +#define _meshtastic_Config_SecurityConfig_PacketSignaturePolicy_ARRAYSIZE ((meshtastic_Config_SecurityConfig_PacketSignaturePolicy)(meshtastic_Config_SecurityConfig_PacketSignaturePolicy_PACKET_SIGNATURE_POLICY_STRICT+1)) + #define meshtastic_Config_DeviceConfig_role_ENUMTYPE meshtastic_Config_DeviceConfig_Role #define meshtastic_Config_DeviceConfig_rebroadcast_mode_ENUMTYPE meshtastic_Config_DeviceConfig_RebroadcastMode @@ -805,6 +824,7 @@ extern "C" { #define meshtastic_Config_BluetoothConfig_mode_ENUMTYPE meshtastic_Config_BluetoothConfig_PairingMode +#define meshtastic_Config_SecurityConfig_packet_signature_policy_ENUMTYPE meshtastic_Config_SecurityConfig_PacketSignaturePolicy @@ -818,7 +838,7 @@ extern "C" { #define meshtastic_Config_DisplayConfig_init_default {0, _meshtastic_Config_DisplayConfig_DeprecatedGpsCoordinateFormat_MIN, 0, 0, 0, _meshtastic_Config_DisplayConfig_DisplayUnits_MIN, _meshtastic_Config_DisplayConfig_OledType_MIN, _meshtastic_Config_DisplayConfig_DisplayMode_MIN, 0, 0, _meshtastic_Config_DisplayConfig_CompassOrientation_MIN, 0, 0, 0} #define meshtastic_Config_LoRaConfig_init_default {0, _meshtastic_Config_LoRaConfig_ModemPreset_MIN, 0, 0, 0, 0, _meshtastic_Config_LoRaConfig_RegionCode_MIN, 0, 0, 0, 0, 0, 0, 0, 0, 0, {0, 0, 0}, 0, 0, _meshtastic_Config_LoRaConfig_FEM_LNA_Mode_MIN, 0} #define meshtastic_Config_BluetoothConfig_init_default {0, _meshtastic_Config_BluetoothConfig_PairingMode_MIN, 0} -#define meshtastic_Config_SecurityConfig_init_default {{0, {0}}, {0, {0}}, 0, {{0, {0}}, {0, {0}}, {0, {0}}}, 0, 0, 0, 0} +#define meshtastic_Config_SecurityConfig_init_default {{0, {0}}, {0, {0}}, 0, {{0, {0}}, {0, {0}}, {0, {0}}}, 0, 0, 0, 0, _meshtastic_Config_SecurityConfig_PacketSignaturePolicy_MIN} #define meshtastic_Config_SessionkeyConfig_init_default {0} #define meshtastic_Config_init_zero {0, {meshtastic_Config_DeviceConfig_init_zero}} #define meshtastic_Config_DeviceConfig_init_zero {_meshtastic_Config_DeviceConfig_Role_MIN, 0, 0, 0, _meshtastic_Config_DeviceConfig_RebroadcastMode_MIN, 0, 0, 0, 0, "", 0, _meshtastic_Config_DeviceConfig_BuzzerMode_MIN} @@ -829,7 +849,7 @@ extern "C" { #define meshtastic_Config_DisplayConfig_init_zero {0, _meshtastic_Config_DisplayConfig_DeprecatedGpsCoordinateFormat_MIN, 0, 0, 0, _meshtastic_Config_DisplayConfig_DisplayUnits_MIN, _meshtastic_Config_DisplayConfig_OledType_MIN, _meshtastic_Config_DisplayConfig_DisplayMode_MIN, 0, 0, _meshtastic_Config_DisplayConfig_CompassOrientation_MIN, 0, 0, 0} #define meshtastic_Config_LoRaConfig_init_zero {0, _meshtastic_Config_LoRaConfig_ModemPreset_MIN, 0, 0, 0, 0, _meshtastic_Config_LoRaConfig_RegionCode_MIN, 0, 0, 0, 0, 0, 0, 0, 0, 0, {0, 0, 0}, 0, 0, _meshtastic_Config_LoRaConfig_FEM_LNA_Mode_MIN, 0} #define meshtastic_Config_BluetoothConfig_init_zero {0, _meshtastic_Config_BluetoothConfig_PairingMode_MIN, 0} -#define meshtastic_Config_SecurityConfig_init_zero {{0, {0}}, {0, {0}}, 0, {{0, {0}}, {0, {0}}, {0, {0}}}, 0, 0, 0, 0} +#define meshtastic_Config_SecurityConfig_init_zero {{0, {0}}, {0, {0}}, 0, {{0, {0}}, {0, {0}}, {0, {0}}}, 0, 0, 0, 0, _meshtastic_Config_SecurityConfig_PacketSignaturePolicy_MIN} #define meshtastic_Config_SessionkeyConfig_init_zero {0} /* Field tags (for use in manual encoding/decoding) */ @@ -925,6 +945,7 @@ extern "C" { #define meshtastic_Config_SecurityConfig_serial_enabled_tag 5 #define meshtastic_Config_SecurityConfig_debug_log_api_enabled_tag 6 #define meshtastic_Config_SecurityConfig_admin_channel_enabled_tag 8 +#define meshtastic_Config_SecurityConfig_packet_signature_policy_tag 9 #define meshtastic_Config_device_tag 1 #define meshtastic_Config_position_tag 2 #define meshtastic_Config_power_tag 3 @@ -1086,7 +1107,8 @@ X(a, STATIC, REPEATED, BYTES, admin_key, 3) \ X(a, STATIC, SINGULAR, BOOL, is_managed, 4) \ X(a, STATIC, SINGULAR, BOOL, serial_enabled, 5) \ X(a, STATIC, SINGULAR, BOOL, debug_log_api_enabled, 6) \ -X(a, STATIC, SINGULAR, BOOL, admin_channel_enabled, 8) +X(a, STATIC, SINGULAR, BOOL, admin_channel_enabled, 8) \ +X(a, STATIC, SINGULAR, UENUM, packet_signature_policy, 9) #define meshtastic_Config_SecurityConfig_CALLBACK NULL #define meshtastic_Config_SecurityConfig_DEFAULT NULL @@ -1130,7 +1152,7 @@ extern const pb_msgdesc_t meshtastic_Config_SessionkeyConfig_msg; #define meshtastic_Config_NetworkConfig_size 204 #define meshtastic_Config_PositionConfig_size 62 #define meshtastic_Config_PowerConfig_size 52 -#define meshtastic_Config_SecurityConfig_size 178 +#define meshtastic_Config_SecurityConfig_size 180 #define meshtastic_Config_SessionkeyConfig_size 0 #define meshtastic_Config_size 207 diff --git a/src/mesh/generated/meshtastic/deviceonly.pb.h b/src/mesh/generated/meshtastic/deviceonly.pb.h index c838dc21d..059398cf0 100644 --- a/src/mesh/generated/meshtastic/deviceonly.pb.h +++ b/src/mesh/generated/meshtastic/deviceonly.pb.h @@ -452,7 +452,7 @@ extern const pb_msgdesc_t meshtastic_BackupPreferences_msg; /* Maximum encoded size of messages (where known) */ /* meshtastic_NodeDatabase_size depends on runtime parameters */ #define MESHTASTIC_MESHTASTIC_DEVICEONLY_PB_H_MAX_SIZE meshtastic_BackupPreferences_size -#define meshtastic_BackupPreferences_size 2738 +#define meshtastic_BackupPreferences_size 2740 #define meshtastic_ChannelFile_size 718 #define meshtastic_DeviceState_size 1944 #define meshtastic_NodeEnvironmentEntry_size 170 diff --git a/src/mesh/generated/meshtastic/localonly.pb.h b/src/mesh/generated/meshtastic/localonly.pb.h index 6fcaed306..c560d5447 100644 --- a/src/mesh/generated/meshtastic/localonly.pb.h +++ b/src/mesh/generated/meshtastic/localonly.pb.h @@ -211,7 +211,7 @@ extern const pb_msgdesc_t meshtastic_LocalModuleConfig_msg; /* Maximum encoded size of messages (where known) */ #define MESHTASTIC_MESHTASTIC_LOCALONLY_PB_H_MAX_SIZE meshtastic_LocalModuleConfig_size -#define meshtastic_LocalConfig_size 757 +#define meshtastic_LocalConfig_size 759 #define meshtastic_LocalModuleConfig_size 1126 #ifdef __cplusplus diff --git a/src/mesh/generated/meshtastic/mesh.pb.h b/src/mesh/generated/meshtastic/mesh.pb.h index 98798173c..7ce8115ba 100644 --- a/src/mesh/generated/meshtastic/mesh.pb.h +++ b/src/mesh/generated/meshtastic/mesh.pb.h @@ -412,6 +412,8 @@ typedef enum _meshtastic_FirmwareEdition { meshtastic_FirmwareEdition_BURNING_MAN = 18, /* Hamvention, the Dayton amateur radio convention */ meshtastic_FirmwareEdition_HAMVENTION = 19, + /* FAB, the international Fab Lab digital fabrication conference */ + meshtastic_FirmwareEdition_FAB = 20, /* Placeholder for DIY and unofficial events */ meshtastic_FirmwareEdition_DIY_EDITION = 127 } meshtastic_FirmwareEdition; @@ -1395,6 +1397,9 @@ typedef struct _meshtastic_DeviceMetadata { /* Bit field of boolean for excluded modules (bitwise OR of ExcludedModules) */ uint32_t excluded_modules; + /* Indicates whether this firmware build includes XEdDSA packet signature verification. + This is a read-only capability and must be false when XEdDSA is not compiled in. */ + bool has_xeddsa; } meshtastic_DeviceMetadata; /* A distinct set of legal modem presets shared by one or more LoRa regions. @@ -1743,7 +1748,7 @@ extern "C" { #define meshtastic_Compressed_init_default {_meshtastic_PortNum_MIN, {0, {0}}} #define meshtastic_NeighborInfo_init_default {0, 0, 0, 0, {meshtastic_Neighbor_init_default, meshtastic_Neighbor_init_default, meshtastic_Neighbor_init_default, meshtastic_Neighbor_init_default, meshtastic_Neighbor_init_default, meshtastic_Neighbor_init_default, meshtastic_Neighbor_init_default, meshtastic_Neighbor_init_default, meshtastic_Neighbor_init_default, meshtastic_Neighbor_init_default}} #define meshtastic_Neighbor_init_default {0, 0, 0, 0} -#define meshtastic_DeviceMetadata_init_default {"", 0, 0, 0, 0, 0, _meshtastic_Config_DeviceConfig_Role_MIN, 0, _meshtastic_HardwareModel_MIN, 0, 0, 0} +#define meshtastic_DeviceMetadata_init_default {"", 0, 0, 0, 0, 0, _meshtastic_Config_DeviceConfig_Role_MIN, 0, _meshtastic_HardwareModel_MIN, 0, 0, 0, 0} #define meshtastic_LoRaPresetGroup_init_default {0, {_meshtastic_Config_LoRaConfig_ModemPreset_MIN, _meshtastic_Config_LoRaConfig_ModemPreset_MIN, _meshtastic_Config_LoRaConfig_ModemPreset_MIN, _meshtastic_Config_LoRaConfig_ModemPreset_MIN, _meshtastic_Config_LoRaConfig_ModemPreset_MIN, _meshtastic_Config_LoRaConfig_ModemPreset_MIN, _meshtastic_Config_LoRaConfig_ModemPreset_MIN, _meshtastic_Config_LoRaConfig_ModemPreset_MIN, _meshtastic_Config_LoRaConfig_ModemPreset_MIN, _meshtastic_Config_LoRaConfig_ModemPreset_MIN, _meshtastic_Config_LoRaConfig_ModemPreset_MIN}, _meshtastic_Config_LoRaConfig_ModemPreset_MIN, 0} #define meshtastic_LoRaRegionPresets_init_default {_meshtastic_Config_LoRaConfig_RegionCode_MIN, 0} #define meshtastic_LoRaRegionPresetMap_init_default {0, {meshtastic_LoRaPresetGroup_init_default, meshtastic_LoRaPresetGroup_init_default, meshtastic_LoRaPresetGroup_init_default, meshtastic_LoRaPresetGroup_init_default, meshtastic_LoRaPresetGroup_init_default, meshtastic_LoRaPresetGroup_init_default, meshtastic_LoRaPresetGroup_init_default, meshtastic_LoRaPresetGroup_init_default}, 0, {meshtastic_LoRaRegionPresets_init_default, meshtastic_LoRaRegionPresets_init_default, meshtastic_LoRaRegionPresets_init_default, meshtastic_LoRaRegionPresets_init_default, meshtastic_LoRaRegionPresets_init_default, meshtastic_LoRaRegionPresets_init_default, meshtastic_LoRaRegionPresets_init_default, meshtastic_LoRaRegionPresets_init_default, meshtastic_LoRaRegionPresets_init_default, meshtastic_LoRaRegionPresets_init_default, meshtastic_LoRaRegionPresets_init_default, meshtastic_LoRaRegionPresets_init_default, meshtastic_LoRaRegionPresets_init_default, meshtastic_LoRaRegionPresets_init_default, meshtastic_LoRaRegionPresets_init_default, meshtastic_LoRaRegionPresets_init_default, meshtastic_LoRaRegionPresets_init_default, meshtastic_LoRaRegionPresets_init_default, meshtastic_LoRaRegionPresets_init_default, meshtastic_LoRaRegionPresets_init_default, meshtastic_LoRaRegionPresets_init_default, meshtastic_LoRaRegionPresets_init_default, meshtastic_LoRaRegionPresets_init_default, meshtastic_LoRaRegionPresets_init_default, meshtastic_LoRaRegionPresets_init_default, meshtastic_LoRaRegionPresets_init_default, meshtastic_LoRaRegionPresets_init_default, meshtastic_LoRaRegionPresets_init_default, meshtastic_LoRaRegionPresets_init_default, meshtastic_LoRaRegionPresets_init_default, meshtastic_LoRaRegionPresets_init_default, meshtastic_LoRaRegionPresets_init_default, meshtastic_LoRaRegionPresets_init_default, meshtastic_LoRaRegionPresets_init_default, meshtastic_LoRaRegionPresets_init_default, meshtastic_LoRaRegionPresets_init_default, meshtastic_LoRaRegionPresets_init_default, meshtastic_LoRaRegionPresets_init_default}} @@ -1782,7 +1787,7 @@ extern "C" { #define meshtastic_Compressed_init_zero {_meshtastic_PortNum_MIN, {0, {0}}} #define meshtastic_NeighborInfo_init_zero {0, 0, 0, 0, {meshtastic_Neighbor_init_zero, meshtastic_Neighbor_init_zero, meshtastic_Neighbor_init_zero, meshtastic_Neighbor_init_zero, meshtastic_Neighbor_init_zero, meshtastic_Neighbor_init_zero, meshtastic_Neighbor_init_zero, meshtastic_Neighbor_init_zero, meshtastic_Neighbor_init_zero, meshtastic_Neighbor_init_zero}} #define meshtastic_Neighbor_init_zero {0, 0, 0, 0} -#define meshtastic_DeviceMetadata_init_zero {"", 0, 0, 0, 0, 0, _meshtastic_Config_DeviceConfig_Role_MIN, 0, _meshtastic_HardwareModel_MIN, 0, 0, 0} +#define meshtastic_DeviceMetadata_init_zero {"", 0, 0, 0, 0, 0, _meshtastic_Config_DeviceConfig_Role_MIN, 0, _meshtastic_HardwareModel_MIN, 0, 0, 0, 0} #define meshtastic_LoRaPresetGroup_init_zero {0, {_meshtastic_Config_LoRaConfig_ModemPreset_MIN, _meshtastic_Config_LoRaConfig_ModemPreset_MIN, _meshtastic_Config_LoRaConfig_ModemPreset_MIN, _meshtastic_Config_LoRaConfig_ModemPreset_MIN, _meshtastic_Config_LoRaConfig_ModemPreset_MIN, _meshtastic_Config_LoRaConfig_ModemPreset_MIN, _meshtastic_Config_LoRaConfig_ModemPreset_MIN, _meshtastic_Config_LoRaConfig_ModemPreset_MIN, _meshtastic_Config_LoRaConfig_ModemPreset_MIN, _meshtastic_Config_LoRaConfig_ModemPreset_MIN, _meshtastic_Config_LoRaConfig_ModemPreset_MIN}, _meshtastic_Config_LoRaConfig_ModemPreset_MIN, 0} #define meshtastic_LoRaRegionPresets_init_zero {_meshtastic_Config_LoRaConfig_RegionCode_MIN, 0} #define meshtastic_LoRaRegionPresetMap_init_zero {0, {meshtastic_LoRaPresetGroup_init_zero, meshtastic_LoRaPresetGroup_init_zero, meshtastic_LoRaPresetGroup_init_zero, meshtastic_LoRaPresetGroup_init_zero, meshtastic_LoRaPresetGroup_init_zero, meshtastic_LoRaPresetGroup_init_zero, meshtastic_LoRaPresetGroup_init_zero, meshtastic_LoRaPresetGroup_init_zero}, 0, {meshtastic_LoRaRegionPresets_init_zero, meshtastic_LoRaRegionPresets_init_zero, meshtastic_LoRaRegionPresets_init_zero, meshtastic_LoRaRegionPresets_init_zero, meshtastic_LoRaRegionPresets_init_zero, meshtastic_LoRaRegionPresets_init_zero, meshtastic_LoRaRegionPresets_init_zero, meshtastic_LoRaRegionPresets_init_zero, meshtastic_LoRaRegionPresets_init_zero, meshtastic_LoRaRegionPresets_init_zero, meshtastic_LoRaRegionPresets_init_zero, meshtastic_LoRaRegionPresets_init_zero, meshtastic_LoRaRegionPresets_init_zero, meshtastic_LoRaRegionPresets_init_zero, meshtastic_LoRaRegionPresets_init_zero, meshtastic_LoRaRegionPresets_init_zero, meshtastic_LoRaRegionPresets_init_zero, meshtastic_LoRaRegionPresets_init_zero, meshtastic_LoRaRegionPresets_init_zero, meshtastic_LoRaRegionPresets_init_zero, meshtastic_LoRaRegionPresets_init_zero, meshtastic_LoRaRegionPresets_init_zero, meshtastic_LoRaRegionPresets_init_zero, meshtastic_LoRaRegionPresets_init_zero, meshtastic_LoRaRegionPresets_init_zero, meshtastic_LoRaRegionPresets_init_zero, meshtastic_LoRaRegionPresets_init_zero, meshtastic_LoRaRegionPresets_init_zero, meshtastic_LoRaRegionPresets_init_zero, meshtastic_LoRaRegionPresets_init_zero, meshtastic_LoRaRegionPresets_init_zero, meshtastic_LoRaRegionPresets_init_zero, meshtastic_LoRaRegionPresets_init_zero, meshtastic_LoRaRegionPresets_init_zero, meshtastic_LoRaRegionPresets_init_zero, meshtastic_LoRaRegionPresets_init_zero, meshtastic_LoRaRegionPresets_init_zero, meshtastic_LoRaRegionPresets_init_zero}} @@ -1985,6 +1990,7 @@ extern "C" { #define meshtastic_DeviceMetadata_hasRemoteHardware_tag 10 #define meshtastic_DeviceMetadata_hasPKC_tag 11 #define meshtastic_DeviceMetadata_excluded_modules_tag 12 +#define meshtastic_DeviceMetadata_has_xeddsa_tag 14 #define meshtastic_LoRaPresetGroup_presets_tag 1 #define meshtastic_LoRaPresetGroup_default_preset_tag 2 #define meshtastic_LoRaPresetGroup_licensed_only_tag 3 @@ -2403,7 +2409,8 @@ X(a, STATIC, SINGULAR, UINT32, position_flags, 8) \ X(a, STATIC, SINGULAR, UENUM, hw_model, 9) \ X(a, STATIC, SINGULAR, BOOL, hasRemoteHardware, 10) \ X(a, STATIC, SINGULAR, BOOL, hasPKC, 11) \ -X(a, STATIC, SINGULAR, UINT32, excluded_modules, 12) +X(a, STATIC, SINGULAR, UINT32, excluded_modules, 12) \ +X(a, STATIC, SINGULAR, BOOL, has_xeddsa, 14) #define meshtastic_DeviceMetadata_CALLBACK NULL #define meshtastic_DeviceMetadata_DEFAULT NULL @@ -2552,7 +2559,7 @@ extern const pb_msgdesc_t meshtastic_ChunkedPayloadResponse_msg; #define meshtastic_ClientNotification_size 482 #define meshtastic_Compressed_size 239 #define meshtastic_Data_size 335 -#define meshtastic_DeviceMetadata_size 54 +#define meshtastic_DeviceMetadata_size 56 #define meshtastic_DuplicatedPublicKey_size 0 #define meshtastic_FileInfo_size 236 #define meshtastic_FromRadio_size 510 diff --git a/src/mesh/http/ContentHandler.cpp b/src/mesh/http/ContentHandler.cpp index 598419c25..95712403e 100644 --- a/src/mesh/http/ContentHandler.cpp +++ b/src/mesh/http/ContentHandler.cpp @@ -74,15 +74,7 @@ void registerHandlers(HTTPServer *insecureServer, HTTPSServer *secureServer) ResourceNode *nodeAPIv1FromRadioOptions = new ResourceNode("/api/v1/fromradio", "OPTIONS", &handleAPIv1FromRadio); ResourceNode *nodeAPIv1FromRadio = new ResourceNode("/api/v1/fromradio", "GET", &handleAPIv1FromRadio); - // ResourceNode *nodeHotspotApple = new ResourceNode("/hotspot-detect.html", "GET", &handleHotspot); - // ResourceNode *nodeHotspotAndroid = new ResourceNode("/generate_204", "GET", &handleHotspot); - ResourceNode *nodeAdmin = new ResourceNode("/admin", "GET", &handleAdmin); - // ResourceNode *nodeAdminSettings = new ResourceNode("/admin/settings", "GET", &handleAdminSettings); - // ResourceNode *nodeAdminSettingsApply = new ResourceNode("/admin/settings/apply", "POST", &handleAdminSettingsApply); - // ResourceNode *nodeAdminFs = new ResourceNode("/admin/fs", "GET", &handleFs); - // ResourceNode *nodeUpdateFs = new ResourceNode("/admin/fs/update", "POST", &handleUpdateFs); - // ResourceNode *nodeDeleteFs = new ResourceNode("/admin/fs/delete", "GET", &handleDeleteFsContent); ResourceNode *nodeRestart = new ResourceNode("/restart", "POST", &handleRestart); ResourceNode *nodeFormUpload = new ResourceNode("/upload", "POST", &handleFormUpload); @@ -100,8 +92,6 @@ void registerHandlers(HTTPServer *insecureServer, HTTPSServer *secureServer) secureServer->registerNode(nodeAPIv1ToRadio); secureServer->registerNode(nodeAPIv1FromRadioOptions); secureServer->registerNode(nodeAPIv1FromRadio); - // secureServer->registerNode(nodeHotspotApple); - // secureServer->registerNode(nodeHotspotAndroid); secureServer->registerNode(nodeRestart); secureServer->registerNode(nodeFormUpload); secureServer->registerNode(nodeJsonScanNetworks); @@ -109,12 +99,7 @@ void registerHandlers(HTTPServer *insecureServer, HTTPSServer *secureServer) secureServer->registerNode(nodeJsonDelete); secureServer->registerNode(nodeJsonReport); secureServer->registerNode(nodeJsonNodes); - // secureServer->registerNode(nodeUpdateFs); - // secureServer->registerNode(nodeDeleteFs); secureServer->registerNode(nodeAdmin); - // secureServer->registerNode(nodeAdminFs); - // secureServer->registerNode(nodeAdminSettings); - // secureServer->registerNode(nodeAdminSettingsApply); secureServer->registerNode(nodeRoot); // This has to be last // Insecure nodes @@ -122,20 +107,13 @@ void registerHandlers(HTTPServer *insecureServer, HTTPSServer *secureServer) insecureServer->registerNode(nodeAPIv1ToRadio); insecureServer->registerNode(nodeAPIv1FromRadioOptions); insecureServer->registerNode(nodeAPIv1FromRadio); - // insecureServer->registerNode(nodeHotspotApple); - // insecureServer->registerNode(nodeHotspotAndroid); insecureServer->registerNode(nodeRestart); insecureServer->registerNode(nodeFormUpload); insecureServer->registerNode(nodeJsonScanNetworks); insecureServer->registerNode(nodeJsonFsBrowseStatic); insecureServer->registerNode(nodeJsonDelete); insecureServer->registerNode(nodeJsonReport); - // insecureServer->registerNode(nodeUpdateFs); - // insecureServer->registerNode(nodeDeleteFs); insecureServer->registerNode(nodeAdmin); - // insecureServer->registerNode(nodeAdminFs); - // insecureServer->registerNode(nodeAdminSettings); - // insecureServer->registerNode(nodeAdminSettingsApply); insecureServer->registerNode(nodeRoot); // This has to be last } @@ -230,36 +208,6 @@ void handleAPIv1ToRadio(HTTPRequest *req, HTTPResponse *res) LOG_DEBUG("webAPI handleAPIv1ToRadio"); } -void htmlDeleteDir(const char *dirname) -{ - - File root = FSCom.open(dirname); - if (!root) { - return; - } - if (!root.isDirectory()) { - return; - } - - File file = root.openNextFile(); - while (file) { - if (file.isDirectory() && !String(file.name()).endsWith(".")) { - htmlDeleteDir(file.name()); - file.flush(); - file.close(); - } else { - String fileName = String(file.name()); - file.flush(); - file.close(); - LOG_DEBUG(" %s", fileName.c_str()); - FSCom.remove(fileName); - } - file = root.openNextFile(); - } - root.flush(); - root.close(); -} - // Escape a string into a JSON double-quoted literal. Matches the previous // SimpleJSON StringifyString behavior (0x00-0x1F and 0x7F -> \u00xx lowercase, // escapes " \ / \b \f \n \r \t, UTF-8 passes through unchanged). @@ -858,45 +806,6 @@ void handleNodes(HTTPRequest *req, HTTPResponse *res) res->print(out.c_str()); } -/* - This supports the Apple Captive Network Assistant (CNA) Portal -*/ -void handleHotspot(HTTPRequest *req, HTTPResponse *res) -{ - LOG_INFO("Hotspot Request"); - - /* - If we don't do a redirect, be sure to return a "Success" message - otherwise iOS will have trouble detecting that the connection to the SoftAP worked. - */ - - // Status code is 200 OK by default. - // We want to deliver a simple HTML page, so we send a corresponding content type: - res->setHeader("Content-Type", "text/html"); - res->setHeader("Access-Control-Allow-Origin", "*"); - res->setHeader("Access-Control-Allow-Methods", "GET"); - - // res->println(""); - res->println(""); -} - -void handleDeleteFsContent(HTTPRequest *req, HTTPResponse *res) -{ - res->setHeader("Content-Type", "text/html"); - res->setHeader("Access-Control-Allow-Origin", "*"); - res->setHeader("Access-Control-Allow-Methods", "GET"); - - res->println("

Meshtastic

"); - res->println("Delete Content in /static/*"); - - LOG_INFO("Delete files from /static/* : "); - - concurrency::LockGuard g(spiLock); - htmlDeleteDir("/static"); - - res->println("


Back to admin"); -} - void handleAdmin(HTTPRequest *req, HTTPResponse *res) { res->setHeader("Content-Type", "text/html"); @@ -909,52 +818,6 @@ void handleAdmin(HTTPRequest *req, HTTPResponse *res) res->println("Device Report
"); } -void handleAdminSettings(HTTPRequest *req, HTTPResponse *res) -{ - res->setHeader("Content-Type", "text/html"); - res->setHeader("Access-Control-Allow-Origin", "*"); - res->setHeader("Access-Control-Allow-Methods", "GET"); - - res->println("

Meshtastic

"); - res->println("This isn't done."); - res->println("
"); - res->println(""); - res->println(""); - res->println(""); - res->println(""); - res->println( - ""); - res->println("
Set?Settingcurrent valuenew value
WiFi SSIDfalse
WiFi Passwordfalse
Smart Position Updatefalse
"); - res->println(""); - res->println(""); - res->println(""); - res->println("


Back to admin"); -} - -void handleAdminSettingsApply(HTTPRequest *req, HTTPResponse *res) -{ - res->setHeader("Content-Type", "text/html"); - res->setHeader("Access-Control-Allow-Origin", "*"); - res->setHeader("Access-Control-Allow-Methods", "POST"); - res->println("

Meshtastic

"); - res->println( - "Settings Applied. "); - - res->println("Settings Applied. Please wait."); -} - -void handleFs(HTTPRequest *req, HTTPResponse *res) -{ - res->setHeader("Content-Type", "text/html"); - res->setHeader("Access-Control-Allow-Origin", "*"); - res->setHeader("Access-Control-Allow-Methods", "GET"); - - res->println("

Meshtastic

"); - res->println("Delete Web Content

Be patient!"); - res->println("


Back to admin"); -} - void handleRestart(HTTPRequest *req, HTTPResponse *res) { res->setHeader("Content-Type", "text/html"); diff --git a/src/mesh/http/ContentHandler.h b/src/mesh/http/ContentHandler.h index ed182ad76..700ef78c8 100644 --- a/src/mesh/http/ContentHandler.h +++ b/src/mesh/http/ContentHandler.h @@ -4,7 +4,6 @@ void registerHandlers(HTTPServer *insecureServer, HTTPSServer *secureServer); // Declare some handler functions for the various URLs on the server void handleAPIv1FromRadio(HTTPRequest *req, HTTPResponse *res); void handleAPIv1ToRadio(HTTPRequest *req, HTTPResponse *res); -void handleHotspot(HTTPRequest *req, HTTPResponse *res); void handleStatic(HTTPRequest *req, HTTPResponse *res); void handleRestart(HTTPRequest *req, HTTPResponse *res); void handleFormUpload(HTTPRequest *req, HTTPResponse *res); @@ -13,12 +12,7 @@ void handleFsBrowseStatic(HTTPRequest *req, HTTPResponse *res); void handleFsDeleteStatic(HTTPRequest *req, HTTPResponse *res); void handleReport(HTTPRequest *req, HTTPResponse *res); void handleNodes(HTTPRequest *req, HTTPResponse *res); -void handleUpdateFs(HTTPRequest *req, HTTPResponse *res); -void handleDeleteFsContent(HTTPRequest *req, HTTPResponse *res); -void handleFs(HTTPRequest *req, HTTPResponse *res); void handleAdmin(HTTPRequest *req, HTTPResponse *res); -void handleAdminSettings(HTTPRequest *req, HTTPResponse *res); -void handleAdminSettingsApply(HTTPRequest *req, HTTPResponse *res); // Interface to the PhoneAPI to access the protobufs with messages class HttpAPI : public PhoneAPI diff --git a/src/mesh/http/ContentHelper.cpp b/src/mesh/http/ContentHelper.cpp index 8f283932b..b35a3fc92 100644 --- a/src/mesh/http/ContentHelper.cpp +++ b/src/mesh/http/ContentHelper.cpp @@ -1,14 +1,3 @@ #include "mesh/http/ContentHelper.h" // #include // #include "main.h" - -void replaceAll(std::string &str, const std::string &from, const std::string &to) -{ - if (from.empty()) - return; - size_t start_pos = 0; - while ((start_pos = str.find(from, start_pos)) != std::string::npos) { - str.replace(start_pos, from.length(), to); - start_pos += to.length(); // In case 'to' contains 'from', like replacing 'x' with 'yx' - } -} diff --git a/src/mesh/http/ContentHelper.h b/src/mesh/http/ContentHelper.h index e5d3a2f57..d4f744aba 100644 --- a/src/mesh/http/ContentHelper.h +++ b/src/mesh/http/ContentHelper.h @@ -3,5 +3,3 @@ #include #define BoolToString(x) ((x) ? "true" : "false") - -void replaceAll(std::string &str, const std::string &from, const std::string &to); diff --git a/src/mesh/udp/UdpMulticastHandler.h b/src/mesh/udp/UdpMulticastHandler.h index 625dc008a..37ab44524 100644 --- a/src/mesh/udp/UdpMulticastHandler.h +++ b/src/mesh/udp/UdpMulticastHandler.h @@ -21,7 +21,7 @@ class UdpMulticastHandler final { public: - UdpMulticastHandler() : isRunning(false) { udpIpAddress = IPAddress(224, 0, 0, 69); } + UdpMulticastHandler() : isRunning(false) { udpIpAddress = IPAddress(239, 0, 0, 69); } void start() { @@ -80,10 +80,12 @@ class UdpMulticastHandler final return; } mp.transport_mechanism = meshtastic_MeshPacket_TransportMechanism_TRANSPORT_MULTICAST_UDP; - // Preserve the whole MeshPacket as received: while payload_variant is encrypted, `channel` is a hash (and is 0 for - // PKI DMs), so it must be copied verbatim for the router to attempt PKI/channel decryption. Keep - // pki_encrypted/public_key too so downstream auth/metadata can reflect PKI usage correctly. + // Authentication metadata is local-only; Router re-establishes it after successful PKI decryption. + mp.pki_encrypted = false; + mp.public_key.size = 0; UniquePacketPoolPacket p = packetPool.allocUniqueCopy(mp); + if (!p) + return; // Unset received SNR/RSSI p->rx_snr = 0; p->rx_rssi = 0; diff --git a/src/meshUtils.h b/src/meshUtils.h index 9a80ac51f..2b7915778 100644 --- a/src/meshUtils.h +++ b/src/meshUtils.h @@ -5,6 +5,16 @@ #include #include +/// Keep a function out of line even when the compiler would rather inline it. Use on helpers that +/// hold a large object on the stack: inlining several of them into one caller makes that caller's +/// frame reserve every helper's locals at once, which on our 8 KB Arduino loopTask is enough to +/// overflow the stack (see issue #11237). +#if defined(__GNUC__) +#define NOINLINE __attribute__((noinline)) +#else +#define NOINLINE +#endif + /// C++ v17+ clamp function, limits a given value to a range defined by lo and hi template constexpr const T &clamp(const T &v, const T &lo, const T &hi) { diff --git a/src/modules/AdminModule.cpp b/src/modules/AdminModule.cpp index e7990a5a5..08b94dc04 100644 --- a/src/modules/AdminModule.cpp +++ b/src/modules/AdminModule.cpp @@ -1,5 +1,6 @@ #include "AdminModule.h" #include "Channels.h" +#include "CryptoEngine.h" #include "DisplayFormatters.h" #include "HardwareRNG.h" #include "MeshService.h" @@ -10,6 +11,7 @@ #include "gps/RTC.h" #include "input/InputBroker.h" #include "meshUtils.h" +#include #include #include #include // for better whitespace handling @@ -29,6 +31,7 @@ #include "Default.h" #include "MeshRadio.h" +#include "MessageStore.h" #include "RadioInterface.h" #include "TypeConversions.h" #include "mesh/RadioLibInterface.h" @@ -68,6 +71,17 @@ AdminModule *adminModule; bool hasOpenEditTransaction; +#if !(MESHTASTIC_EXCLUDE_PKI_KEYGEN || MESHTASTIC_EXCLUDE_PKI) +static bool licensedIdentityWillMigrate() +{ + if (config.security.private_key.size != 32 || config.security.public_key.size != 32) + return true; + if (nodeDB->checkLowEntropyPublicKey(config.security.public_key)) + return true; + return crc32Buffer(config.security.public_key.bytes, config.security.public_key.size) != nodeDB->getNodeNum(); +} +#endif + /// A special reserved string to indicate strings we can not share with external nodes. We will use this 'reserved' word instead. /// Also, to make setting work correctly, if someone tries to set a string to this reserved value we assume they don't really want /// a change. @@ -537,7 +551,14 @@ bool AdminModule::handleReceivedProtobuf(const meshtastic_MeshPacket &mp, meshta if (node != NULL) { if (nodeDB->setProtectedFlag(node, NODEINFO_BITFIELD_IS_IGNORED_MASK, true)) { nodeDB->eraseNodeSatellites(node->num); +#if HAS_SCREEN || defined(MESHTASTIC_INCLUDE_NICHE_GRAPHICS) + messageStore.deleteAllMessagesFromNode(node->num); +#endif saveChanges(SEGMENT_NODEDATABASE, false); +#if HAS_SCREEN + if (screen) + screen->setFrames(graphics::Screen::FOCUS_PRESERVE); +#endif } else if (mp.from == 0) { // local request from the phone - tell the user why it didn't take sendWarning(NodeDB::PROTECTED_CAP_WARN_FMT, "ignore", r->set_ignored_node, MAX_NUM_NODES - 2); } else { @@ -672,22 +693,41 @@ bool AdminModule::handleReceivedProtobuf(const meshtastic_MeshPacket &mp, meshta #endif default: - meshtastic_AdminMessage res = meshtastic_AdminMessage_init_default; - AdminMessageHandleResult handleResult = MeshModule::handleAdminMessageForAllModules(mp, r, &res); - - if (handleResult == AdminMessageHandleResult::HANDLED_WITH_RESPONSE) { - setPassKey(&res); - myReply = allocDataProtobuf(res); - } else if (mp.decoded.want_response) { - LOG_DEBUG("Module API did not respond to admin message. req.variant=%d", r->which_payload_variant); - } else if (handleResult != AdminMessageHandleResult::HANDLED) { - // Probably a message sent by us or sent to our local node. FIXME, we should avoid scanning these messages - LOG_DEBUG("Module API did not handle admin message %d", r->which_payload_variant); - } + handleViaModuleApi(mp, r); break; } // Allow any observers (e.g. the UI) to handle/respond + handleViaObservers(r); + + // If asked for a response and it is not yet set, generate an 'ACK' response + if (mp.decoded.want_response && !myReply) { + myReply = allocErrorResponse(meshtastic_Routing_Error_NONE, &mp); + } + if (mp.pki_encrypted && myReply) { + myReply->pki_encrypted = true; + } + return handled; +} + +void AdminModule::handleViaModuleApi(const meshtastic_MeshPacket &mp, meshtastic_AdminMessage *r) +{ + meshtastic_AdminMessage res = meshtastic_AdminMessage_init_default; + AdminMessageHandleResult handleResult = MeshModule::handleAdminMessageForAllModules(mp, r, &res); + + if (handleResult == AdminMessageHandleResult::HANDLED_WITH_RESPONSE) { + setPassKey(&res); + myReply = allocDataProtobuf(res); + } else if (mp.decoded.want_response) { + LOG_DEBUG("Module API did not respond to admin message. req.variant=%d", r->which_payload_variant); + } else if (handleResult != AdminMessageHandleResult::HANDLED) { + // Probably a message sent by us or sent to our local node. FIXME, we should avoid scanning these messages + LOG_DEBUG("Module API did not handle admin message %d", r->which_payload_variant); + } +} + +void AdminModule::handleViaObservers(const meshtastic_AdminMessage *r) +{ AdminMessageHandleResult observerResult = AdminMessageHandleResult::NOT_HANDLED; meshtastic_AdminMessage observerResponse = meshtastic_AdminMessage_init_default; AdminModule_ObserverData observerData = { @@ -705,15 +745,6 @@ bool AdminModule::handleReceivedProtobuf(const meshtastic_MeshPacket &mp, meshta } else if (observerResult == AdminMessageHandleResult::HANDLED) { LOG_DEBUG("Observer handled admin message"); } - - // If asked for a response and it is not yet set, generate an 'ACK' response - if (mp.decoded.want_response && !myReply) { - myReply = allocErrorResponse(meshtastic_Routing_Error_NONE, &mp); - } - if (mp.pki_encrypted && myReply) { - myReply->pki_encrypted = true; - } - return handled; } void AdminModule::handleGetModuleConfigResponse(const meshtastic_MeshPacket &mp, meshtastic_AdminMessage *r) @@ -746,6 +777,8 @@ void AdminModule::handleGetModuleConfigResponse(const meshtastic_MeshPacket &mp, void AdminModule::handleSetOwner(const meshtastic_User &o) { int changed = 0; + bool identityUpdated = false; + bool channelsSanitized = false; if (*o.long_name) { // Apps built against the older 39-byte limit may send longer names; clamp @@ -764,15 +797,28 @@ void AdminModule::handleSetOwner(const meshtastic_User &o) owner.short_name[sizeof(owner.short_name) - 1] = '\0'; sanitizeUtf8(owner.short_name, sizeof(owner.short_name)); } - snprintf(owner.id, sizeof(owner.id), "!%08x", nodeDB->getNodeNum()); - if (owner.is_licensed != o.is_licensed) { changed = 1; +#if !(MESHTASTIC_EXCLUDE_PKI_KEYGEN || MESHTASTIC_EXCLUDE_PKI) + const bool identityWillMigrate = + o.is_licensed && config.lora.region != meshtastic_Config_LoRaConfig_RegionCode_UNSET && licensedIdentityWillMigrate(); + if (identityWillMigrate) + sendWarning(licensedIdentityMigrationMessage); +#endif owner.is_licensed = o.is_licensed; if (channels.ensureLicensedOperation()) { warnLicensedMode(); + channelsSanitized = true; } +#if !(MESHTASTIC_EXCLUDE_PKI_KEYGEN || MESHTASTIC_EXCLUDE_PKI) + if (owner.is_licensed && config.lora.region != meshtastic_Config_LoRaConfig_RegionCode_UNSET) { + identityUpdated = nodeDB->generateCryptoKeyPair(); + if (identityWillMigrate) + nodeDB->licensedIdentityMigrationPending = false; + } +#endif } + snprintf(owner.id, sizeof(owner.id), "!%08x", nodeDB->getNodeNum()); if (owner.has_is_unmessagable != o.has_is_unmessagable || (o.has_is_unmessagable && owner.is_unmessagable != o.is_unmessagable)) { changed = 1; @@ -782,7 +828,8 @@ void AdminModule::handleSetOwner(const meshtastic_User &o) if (changed) { // If nothing really changed, don't broadcast on the network or write to flash service->reloadOwner(!hasOpenEditTransaction); - saveChanges(SEGMENT_DEVICESTATE | SEGMENT_NODEDATABASE); + saveChanges(SEGMENT_DEVICESTATE | SEGMENT_NODEDATABASE | (identityUpdated ? SEGMENT_CONFIG : 0) | + (channelsSanitized ? SEGMENT_CHANNELS : 0)); } } @@ -807,6 +854,23 @@ static void reconcileAccelerometerThread(bool wasOn, bool nowOn, bool otherFeatu } #endif +// A "regenerate keys" client sends a blank SecurityConfig holding only the new private key, rather than the +// config it read from us. Detect that shape - new private key, every other field at its proto default - so it +// isn't mistaken for "and clear everything else". +static bool isBareKeypairRotation(const meshtastic_Config_SecurityConfig &incoming, + const meshtastic_Config_SecurityConfig ¤t) +{ + if (incoming.private_key.size != 32) + return false; + if (current.private_key.size == 32 && memcmp(incoming.private_key.bytes, current.private_key.bytes, 32) == 0) + return false; + + return incoming.admin_key_count == 0 && !incoming.is_managed && !incoming.serial_enabled && !incoming.debug_log_api_enabled && + !incoming.admin_channel_enabled && + incoming.packet_signature_policy == + meshtastic_Config_SecurityConfig_PacketSignaturePolicy_PACKET_SIGNATURE_POLICY_COMPATIBLE; +} + void AdminModule::handleSetConfig(const meshtastic_Config &c, bool fromOthers) { auto changes = SEGMENT_CONFIG; @@ -903,11 +967,15 @@ void AdminModule::handleSetConfig(const meshtastic_Config &c, bool fromOthers) config.power.on_battery_shutdown_after_secs = 30; } break; - case meshtastic_Config_network_tag: + case meshtastic_Config_network_tag: { LOG_INFO("Set config: WiFi"); config.has_network = true; + char prevPsk[sizeof(config.network.wifi_psk)]; + memcpy(prevPsk, config.network.wifi_psk, sizeof(prevPsk)); config.network = c.payload_variant.network; + writeSecret(config.network.wifi_psk, sizeof(config.network.wifi_psk), prevPsk); break; + } case meshtastic_Config_display_tag: LOG_INFO("Set config: Display"); config.has_display = true; @@ -964,7 +1032,7 @@ void AdminModule::handleSetConfig(const meshtastic_Config &c, bool fromOthers) // If we're setting region for the first time, init the region and regenerate the keys if (isRegionUnset && validatedLora.region > meshtastic_Config_LoRaConfig_RegionCode_UNSET) { #if !(MESHTASTIC_EXCLUDE_PKI_KEYGEN || MESHTASTIC_EXCLUDE_PKI) - if (crypto) { + if (crypto && !owner.is_licensed) { crypto->ensurePkiKeys(config.security, owner); } #endif @@ -977,6 +1045,17 @@ void AdminModule::handleSetConfig(const meshtastic_Config &c, bool fromOthers) } // Ensure initRegion() uses the newly validated region config.lora.region = validatedLora.region; +#if !(MESHTASTIC_EXCLUDE_PKI_KEYGEN || MESHTASTIC_EXCLUDE_PKI) + if (owner.is_licensed && isRegionUnset && validatedLora.region > meshtastic_Config_LoRaConfig_RegionCode_UNSET) { + const bool identityWillMigrate = licensedIdentityWillMigrate(); + if (identityWillMigrate) + sendWarning(licensedIdentityMigrationMessage); + nodeDB->generateCryptoKeyPair(); + changes |= SEGMENT_DEVICESTATE | SEGMENT_NODEDATABASE; + if (identityWillMigrate) + nodeDB->licensedIdentityMigrationPending = false; + } +#endif initRegion(); if (getEffectiveDutyCycle() < 100) { validatedLora.ignore_mqtt = true; // Ignore MQTT by default if region has a duty cycle limit @@ -985,7 +1064,7 @@ void AdminModule::handleSetConfig(const meshtastic_Config &c, bool fromOthers) // Default root is in use, so subscribe to the appropriate MQTT topic for this region snprintf(moduleConfig.mqtt.root, sizeof(moduleConfig.mqtt.root), "%s/%s", default_mqtt_root, myRegion->name); } - changes = SEGMENT_CONFIG | SEGMENT_MODULECONFIG; + changes |= SEGMENT_CONFIG | SEGMENT_MODULECONFIG; } else { // Region validation has failed, so just copy all of the old config over the new config validatedLora = oldLoraConfig; @@ -1096,6 +1175,26 @@ void AdminModule::handleSetConfig(const meshtastic_Config &c, bool fromOthers) incoming.private_key = config.security.private_key; incoming.public_key = config.security.public_key; } + // Rotating the keypair must not drop the admin keys - that locks the owner out of remote admin with no + // recourse but a physical connection. Clearing admin keys still works via a SET that leaves the private + // key alone and sends an empty list. + if (isBareKeypairRotation(incoming, config.security)) { + LOG_INFO("Security set is a bare keypair rotation; preserving remaining security config"); + meshtastic_Config_SecurityConfig rotated = config.security; + rotated.public_key = incoming.public_key; // usually empty; derived from the private key below + rotated.private_key = incoming.private_key; + incoming = rotated; + } +#if MESHTASTIC_EXCLUDE_PKI || MESHTASTIC_EXCLUDE_XEDDSA + if (incoming.packet_signature_policy != + meshtastic_Config_SecurityConfig_PacketSignaturePolicy_PACKET_SIGNATURE_POLICY_COMPATIBLE) { + incoming.packet_signature_policy = + meshtastic_Config_SecurityConfig_PacketSignaturePolicy_PACKET_SIGNATURE_POLICY_COMPATIBLE; + const char *warning = "Packet authenticity policy is unavailable on this firmware build"; + LOG_WARN(warning); + sendWarning(warning); + } +#endif config.security = incoming; #if !(MESHTASTIC_EXCLUDE_PKI_KEYGEN) && !(MESHTASTIC_EXCLUDE_PKI) // First provisioning (no key) generates one; a private key supplied without its public key derives it. @@ -1158,7 +1257,12 @@ bool AdminModule::handleSetModuleConfig(const meshtastic_ModuleConfig &c) // Disable Bluetooth to prevent interference during MQTT configuration disableBluetooth(); moduleConfig.has_mqtt = true; - moduleConfig.mqtt = c.payload_variant.mqtt; + { + char prevPass[sizeof(moduleConfig.mqtt.password)]; + memcpy(prevPass, moduleConfig.mqtt.password, sizeof(prevPass)); + moduleConfig.mqtt = c.payload_variant.mqtt; + writeSecret(moduleConfig.mqtt.password, sizeof(moduleConfig.mqtt.password), prevPass); + } #endif break; case meshtastic_ModuleConfig_serial_tag: @@ -1244,6 +1348,11 @@ bool AdminModule::handleSetModuleConfig(const meshtastic_ModuleConfig &c) moduleConfig.has_traffic_management = true; moduleConfig.traffic_management = c.payload_variant.traffic_management; break; + case meshtastic_ModuleConfig_tak_tag: + LOG_INFO("Set module config: TAK"); + moduleConfig.has_tak = true; + moduleConfig.tak = c.payload_variant.tak; + break; #if !MESHTASTIC_EXCLUDE_BEACON case meshtastic_ModuleConfig_mesh_beacon_tag: { LOG_INFO("Set module config: MeshBeacon"); @@ -1381,6 +1490,9 @@ void AdminModule::handleGetOwner(const meshtastic_MeshPacket &req) res.which_payload_variant = meshtastic_AdminMessage_get_owner_response_tag; setPassKey(&res); myReply = allocDataProtobuf(res); + if (!myReply) { + return; + } if (req.pki_encrypted) { myReply->pki_encrypted = true; } @@ -1412,8 +1524,9 @@ void AdminModule::handleGetConfig(const meshtastic_MeshPacket &req, const uint32 LOG_INFO("Get config: Network"); res.get_config_response.which_payload_variant = meshtastic_Config_network_tag; res.get_config_response.payload_variant.network = config.network; - writeSecret(res.get_config_response.payload_variant.network.wifi_psk, - sizeof(res.get_config_response.payload_variant.network.wifi_psk), config.network.wifi_psk); + if (req.from != 0) + strncpy(res.get_config_response.payload_variant.network.wifi_psk, secretReserved, + sizeof(res.get_config_response.payload_variant.network.wifi_psk)); break; case meshtastic_AdminMessage_ConfigType_DISPLAY_CONFIG: LOG_INFO("Get config: Display"); @@ -1464,6 +1577,9 @@ void AdminModule::handleGetConfig(const meshtastic_MeshPacket &req, const uint32 res.which_payload_variant = meshtastic_AdminMessage_get_config_response_tag; setPassKey(&res); myReply = allocDataProtobuf(res); + if (!myReply) { + return; + } if (req.pki_encrypted) { myReply->pki_encrypted = true; } @@ -1481,6 +1597,9 @@ void AdminModule::handleGetModuleConfig(const meshtastic_MeshPacket &req, const configName = "MQTT"; res.get_module_config_response.which_payload_variant = meshtastic_ModuleConfig_mqtt_tag; res.get_module_config_response.payload_variant.mqtt = moduleConfig.mqtt; + if (req.from != 0) + strncpy(res.get_module_config_response.payload_variant.mqtt.password, secretReserved, + sizeof(res.get_module_config_response.payload_variant.mqtt.password)); break; case meshtastic_AdminMessage_ModuleConfigType_SERIAL_CONFIG: configName = "Serial"; @@ -1552,6 +1671,11 @@ void AdminModule::handleGetModuleConfig(const meshtastic_MeshPacket &req, const res.get_module_config_response.which_payload_variant = meshtastic_ModuleConfig_traffic_management_tag; res.get_module_config_response.payload_variant.traffic_management = moduleConfig.traffic_management; break; + case meshtastic_AdminMessage_ModuleConfigType_TAK_CONFIG: + configName = "TAK"; + res.get_module_config_response.which_payload_variant = meshtastic_ModuleConfig_tak_tag; + res.get_module_config_response.payload_variant.tak = moduleConfig.tak; + break; #if !MESHTASTIC_EXCLUDE_BEACON case meshtastic_AdminMessage_ModuleConfigType_MESHBEACON_CONFIG: configName = "MeshBeacon"; @@ -1573,6 +1697,9 @@ void AdminModule::handleGetModuleConfig(const meshtastic_MeshPacket &req, const res.which_payload_variant = meshtastic_AdminMessage_get_module_config_response_tag; setPassKey(&res); myReply = allocDataProtobuf(res); + if (!myReply) { + return; + } if (req.pki_encrypted) { myReply->pki_encrypted = true; } @@ -1600,6 +1727,9 @@ void AdminModule::handleGetNodeRemoteHardwarePins(const meshtastic_MeshPacket &r } setPassKey(&r); myReply = allocDataProtobuf(r); + if (!myReply) { + return; + } if (req.pki_encrypted) { myReply->pki_encrypted = true; } @@ -1619,6 +1749,9 @@ void AdminModule::handleGetDeviceMetadata(const meshtastic_MeshPacket &req) r.which_payload_variant = meshtastic_AdminMessage_get_device_metadata_response_tag; setPassKey(&r); myReply = allocDataProtobuf(r); + if (!myReply) { + return; + } if (req.pki_encrypted) { myReply->pki_encrypted = true; } @@ -1694,6 +1827,9 @@ void AdminModule::handleGetDeviceConnectionStatus(const meshtastic_MeshPacket &r r.which_payload_variant = meshtastic_AdminMessage_get_device_connection_status_response_tag; setPassKey(&r); myReply = allocDataProtobuf(r); + if (!myReply) { + return; + } if (req.pki_encrypted) { myReply->pki_encrypted = true; } @@ -1708,6 +1844,9 @@ void AdminModule::handleGetChannel(const meshtastic_MeshPacket &req, uint32_t ch r.which_payload_variant = meshtastic_AdminMessage_get_channel_response_tag; setPassKey(&r); myReply = allocDataProtobuf(r); + if (!myReply) { + return; + } if (req.pki_encrypted) { myReply->pki_encrypted = true; } @@ -1720,6 +1859,9 @@ void AdminModule::handleGetDeviceUIConfig(const meshtastic_MeshPacket &req) r.which_payload_variant = meshtastic_AdminMessage_get_ui_config_response_tag; r.get_ui_config_response = uiconfig; myReply = allocDataProtobuf(r); + if (!myReply) { + return; + } if (req.pki_encrypted) { myReply->pki_encrypted = true; } @@ -1735,6 +1877,9 @@ void AdminModule::reboot(int32_t seconds) void AdminModule::saveChanges(int saveWhat, bool shouldReboot) { +#ifdef PIO_UNIT_TESTING + lastSaveWhatForTest = saveWhat; +#endif if (!hasOpenEditTransaction) { LOG_INFO("Save changes to disk"); service->reloadConfig(saveWhat); // Calls saveToDisk among other things @@ -1788,6 +1933,17 @@ void AdminModule::handleSetHamMode(const meshtastic_HamParameters &p) config.device.rebroadcast_mode = meshtastic_Config_DeviceConfig_RebroadcastMode_LOCAL_ONLY; // Remove PSK of primary channel for plaintext amateur usage +#if !(MESHTASTIC_EXCLUDE_PKI_KEYGEN || MESHTASTIC_EXCLUDE_PKI) + if (config.lora.region != meshtastic_Config_LoRaConfig_RegionCode_UNSET) { + const bool identityWillMigrate = licensedIdentityWillMigrate(); + if (identityWillMigrate) + sendWarning(licensedIdentityMigrationMessage); + nodeDB->generateCryptoKeyPair(); + if (identityWillMigrate) + nodeDB->licensedIdentityMigrationPending = false; + } +#endif + if (channels.ensureLicensedOperation()) { warnLicensedMode(); } @@ -1814,8 +1970,14 @@ void AdminModule::setPassKey(meshtastic_AdminMessage *res) if (!sessionPasskeyValid || !Throttle::isWithinTimespanMs(session_time, 150 * 1000UL)) { // Session passkey authenticates admin replies, so it must be unpredictable: prefer the // hardware RNG, falling back to the seeded CSPRNG only when no hardware source exists. - if (!HardwareRNG::fill(session_passkey, sizeof(session_passkey))) - CryptRNG.rand(session_passkey, sizeof(session_passkey)); + // Hold cryptLock like the signing path does: this runs on the admin receive path, which on + // nRF52 is the BLE task, and the fill toggles the CC310 that packet crypto also uses while + // the CryptRNG state is shared with signing. + { + concurrency::LockGuard g(cryptLock); + if (!HardwareRNG::fill(session_passkey, sizeof(session_passkey))) + CryptRNG.rand(session_passkey, sizeof(session_passkey)); + } session_time = millis(); sessionPasskeyValid = true; } @@ -1896,7 +2058,15 @@ void AdminModule::noteOutgoingAdminRequest(const meshtastic_MeshPacket &p) if (!responseVariant) return; // not a getter whose response we can pair - const bool keyValid = p.pki_encrypted && p.public_key.size == 32; + // Pin the key perhapsEncode will actually encrypt to, resolved the same way it resolves it. + // p.public_key is NOT it: nothing populates that field on the outgoing path (only perhapsDecode + // sets it, on RX), so reading it here pinned nothing and left `from` as the sole check. + bool keyValid = false; + meshtastic_NodeInfoLite_public_key_t destKey = {0, {0}}; +#if !(MESHTASTIC_EXCLUDE_PKI) + const bool haveDestKey = nodeDB->copyPublicKey(p.to, destKey); + keyValid = haveDestKey && wouldEncryptWithPKC(&p, p.channel, haveDestKey); +#endif // One entry per request (a client sends N indexed get_channel requests, each answered once, so // entries must not merge). Free slot, else evict the oldest by rollover-safe elapsed time. @@ -1915,6 +2085,7 @@ void AdminModule::noteOutgoingAdminRequest(const meshtastic_MeshPacket &p) } slot->to = p.to; + slot->requestId = p.id; slot->sentAtMs = millis(); slot->expectedResponse = responseVariant; slot->moduleConfigType = admin.which_payload_variant == meshtastic_AdminMessage_get_module_config_request_tag @@ -1922,7 +2093,7 @@ void AdminModule::noteOutgoingAdminRequest(const meshtastic_MeshPacket &p) : 0; slot->keyValid = keyValid; if (keyValid) - memcpy(slot->key, p.public_key.bytes, 32); + memcpy(slot->key, destKey.bytes, 32); else memset(slot->key, 0, 32); LOG_DEBUG("Admin request sent to 0x%08x, expecting its response", p.to); @@ -1935,6 +2106,11 @@ bool AdminModule::responseIsSolicited(const meshtastic_MeshPacket &mp, pb_size_t for (auto &o : outstandingAdminRequests) { if (o.to != mp.from || o.expectedResponse != responseVariant) continue; + // mp.from is unauthenticated, so also require the response to echo our request's packet id + // (setReplyTo puts it in decoded.request_id). A blind injector must now guess it. Id 0 is + // no token at all - an omitted request_id decodes to 0 - so such a slot never matches. + if (o.requestId == 0 || mp.decoded.request_id != o.requestId) + continue; if (!Throttle::isWithinTimespanMs(o.sentAtMs, kOutstandingAdminRequestMs)) { o.to = 0; // lapsed; free the slot and keep looking for another live match continue; diff --git a/src/modules/AdminModule.h b/src/modules/AdminModule.h index 5c1a6ef58..020e9f0d6 100644 --- a/src/modules/AdminModule.h +++ b/src/modules/AdminModule.h @@ -3,6 +3,7 @@ #include #endif #include "ProtobufModule.h" +#include "meshUtils.h" #include #if HAS_WIFI #include "mesh/wifi/WiFiAPClient.h" @@ -39,6 +40,9 @@ class AdminModule : public ProtobufModule, public Obser private: bool hasOpenEditTransaction = false; +#ifdef PIO_UNIT_TESTING + int lastSaveWhatForTest = 0; +#endif uint8_t session_passkey[8] = {0}; uint32_t session_time = 0; // millis() when the current session passkey was issued @@ -48,16 +52,24 @@ class AdminModule : public ProtobufModule, public Obser /** * Getters + * + * Each of the NOINLINE ones below builds a whole meshtastic_AdminMessage (480 bytes) on the + * stack. They are only ever reached from one case of handleReceivedProtobuf()'s switch, but + * when the compiler inlines them the dispatcher's frame has to reserve a slot for every one of + * them at once - measured at 3008 bytes on ESP32-S3, 37% of the 8 KB Arduino loopTask stack + * that also has to carry PhoneAPI, the router, nanopb and LittleFS below it. Keeping them out + * of line means only the request actually being served pays for its response buffer. + * See issue #11237. */ void handleGetModuleConfigResponse(const meshtastic_MeshPacket &req, meshtastic_AdminMessage *p); - void handleGetOwner(const meshtastic_MeshPacket &req); - void handleGetConfig(const meshtastic_MeshPacket &req, uint32_t configType); - void handleGetModuleConfig(const meshtastic_MeshPacket &req, uint32_t configType); - void handleGetChannel(const meshtastic_MeshPacket &req, uint32_t channelIndex); - void handleGetDeviceMetadata(const meshtastic_MeshPacket &req); - void handleGetDeviceConnectionStatus(const meshtastic_MeshPacket &req); - void handleGetNodeRemoteHardwarePins(const meshtastic_MeshPacket &req); - void handleGetDeviceUIConfig(const meshtastic_MeshPacket &req); + NOINLINE void handleGetOwner(const meshtastic_MeshPacket &req); + NOINLINE void handleGetConfig(const meshtastic_MeshPacket &req, uint32_t configType); + NOINLINE void handleGetModuleConfig(const meshtastic_MeshPacket &req, uint32_t configType); + NOINLINE void handleGetChannel(const meshtastic_MeshPacket &req, uint32_t channelIndex); + NOINLINE void handleGetDeviceMetadata(const meshtastic_MeshPacket &req); + NOINLINE void handleGetDeviceConnectionStatus(const meshtastic_MeshPacket &req); + NOINLINE void handleGetNodeRemoteHardwarePins(const meshtastic_MeshPacket &req); + NOINLINE void handleGetDeviceUIConfig(const meshtastic_MeshPacket &req); /** * Setters */ @@ -90,10 +102,11 @@ class AdminModule : public ProtobufModule, public Obser static constexpr uint32_t kOutstandingAdminRequestMs = 300 * 1000; // same window as the session passkey struct OutstandingAdminRequest { NodeNum to; // 0 = free slot + uint32_t requestId; // our request's packet id; the response must echo it as request_id uint32_t sentAtMs; // millis() when this request went out pb_size_t expectedResponse; // the one response variant this request authorizes uint8_t moduleConfigType; // for get_module_config_request: which ModuleConfigType we asked - uint8_t key[32]; // pinned destination key when the request went out over PKC + uint8_t key[32]; // pinned destination key when the request goes out over PKC bool keyValid; }; OutstandingAdminRequest outstandingAdminRequests[kOutstandingAdminRequests] = {}; @@ -101,6 +114,13 @@ class AdminModule : public ProtobufModule, public Obser /// Whether a response (variant responseVariant, module-config subtype moduleConfigTag or 0) /// from mp.from answers a request we sent; consumes the matched request so it can't be replayed. bool responseIsSolicited(const meshtastic_MeshPacket &mp, pb_size_t responseVariant, pb_size_t moduleConfigTag); + + /// Offer an admin message we have no case for to the module API, and let observers (e.g. the UI) + /// see every admin message. Both build a response on the stack, so like the getters above they + /// stay out of line to keep handleReceivedProtobuf()'s frame small. + NOINLINE void handleViaModuleApi(const meshtastic_MeshPacket &mp, meshtastic_AdminMessage *r); + NOINLINE void handleViaObservers(const meshtastic_AdminMessage *r); + void handleStoreDeviceUIConfig(const meshtastic_DeviceUIConfig &uicfg); void handleSendInputEvent(const meshtastic_AdminMessage_InputEvent &inputEvent); void reboot(int32_t seconds); @@ -138,9 +158,12 @@ class AdminModule : public ProtobufModule, public Obser static constexpr const char *licensedModeMessage = "Licensed mode activated, removing admin channel and encryption from all channels"; +static constexpr const char *licensedIdentityMigrationMessage = + "Licensed signing requires an identity key; this node identity will change after key generation"; + static constexpr const char *publicChannelPrecisionMessage = "Precise position is not allowed on a public (open / known-key) channel; reduced to coarse precision"; extern AdminModule *adminModule; -void disableBluetooth(); \ No newline at end of file +void disableBluetooth(); diff --git a/src/modules/CannedMessageModule.cpp b/src/modules/CannedMessageModule.cpp index 771066054..d86878a8d 100644 --- a/src/modules/CannedMessageModule.cpp +++ b/src/modules/CannedMessageModule.cpp @@ -82,7 +82,6 @@ CannedMessageModule::CannedMessageModule() void CannedMessageModule::LaunchWithDestination(NodeNum newDest, uint8_t newChannel) { // Do NOT override explicit broadcast replies - // Only reuse lastDest in LaunchRepeatDestination() if (newDest == 0) { dest = NODENUM_BROADCAST; @@ -114,19 +113,9 @@ void CannedMessageModule::LaunchWithDestination(NodeNum newDest, uint8_t newChan LOG_DEBUG("[CannedMessage] LaunchWithDestination dest=0x%08x ch=%d", dest, channel); } -void CannedMessageModule::LaunchRepeatDestination() -{ - if (!lastDestSet) { - LaunchWithDestination(NODENUM_BROADCAST, 0); - } else { - LaunchWithDestination(lastDest, lastChannel); - } -} - void CannedMessageModule::LaunchFreetextWithDestination(NodeNum newDest, uint8_t newChannel) { // Do NOT override explicit broadcast replies - // Only reuse lastDest in LaunchRepeatDestination() if (newDest == 0) { dest = NODENUM_BROADCAST; @@ -190,7 +179,7 @@ int CannedMessageModule::splitConfiguredMessages() while (i < upTo) { if (this->messageBuffer[i] == '|') { this->messageBuffer[i] = '\0'; // End previous message - if (tempCount >= CANNED_MESSAGE_MODULE_MESSAGE_MAX_COUNT) + if (tempCount >= CANNED_MESSAGE_MODULE_MESSAGE_MAX_COUNT - 1) break; tempMessages[tempCount++] = (this->messageBuffer + i + 1); } @@ -308,12 +297,6 @@ void CannedMessageModule::updateDestinationSelectionList() } } -// Returns true if character input is currently allowed (used for search/freetext states) -bool CannedMessageModule::isCharInputAllowed() const -{ - return runState == CANNED_MESSAGE_RUN_STATE_FREETEXT || runState == CANNED_MESSAGE_RUN_STATE_DESTINATION_SELECTION; -} - static int getRowHeightForEmoteText(const char *text, int minimumHeight, int emoteSpacing = 2) { // Grow the row only when an emote is taller than the font. @@ -1039,6 +1022,8 @@ void CannedMessageModule::sendText(NodeNum dest, ChannelIndex channel, const cha lastDestSet = true; meshtastic_MeshPacket *p = allocDataPacket(); + if (!p) + return; p->to = dest; p->channel = channel; p->want_ack = true; @@ -1071,7 +1056,7 @@ void CannedMessageModule::sendText(NodeNum dest, ChannelIndex channel, const cha p->decoded.payload.size = strlen(message); memcpy(p->decoded.payload.bytes, message, p->decoded.payload.size); - if (moduleConfig.canned_message.send_bell && p->decoded.payload.size < meshtastic_Constants_DATA_PAYLOAD_LEN) { + if (moduleConfig.canned_message.send_bell && p->decoded.payload.size + 1 < meshtastic_Constants_DATA_PAYLOAD_LEN) { p->decoded.payload.bytes[p->decoded.payload.size++] = 7; p->decoded.payload.bytes[p->decoded.payload.size] = '\0'; } @@ -1437,13 +1422,6 @@ bool CannedMessageModule::shouldDraw() this->runState == CANNED_MESSAGE_RUN_STATE_EMOTE_PICKER); } -// Has the user defined any canned messages? -// Expose publicly whether canned message module is ready for use -bool CannedMessageModule::hasMessages() -{ - return (this->messagesCount > 0); -} - int CannedMessageModule::getNextIndex() { if (this->currentMessageIndex >= (this->messagesCount - 1)) { diff --git a/src/modules/CannedMessageModule.h b/src/modules/CannedMessageModule.h index f6cb4d011..fe12bd468 100644 --- a/src/modules/CannedMessageModule.h +++ b/src/modules/CannedMessageModule.h @@ -55,7 +55,6 @@ class CannedMessageModule : public SinglePortModule, public Observable (uint32_t)_meshtastic_ModuleConfig_DetectionSensorConfig_TriggerType_MAX) + return _meshtastic_ModuleConfig_DetectionSensorConfig_TriggerType_MIN; + return (meshtastic_ModuleConfig_DetectionSensorConfig_TriggerType)configured; +} + int32_t DetectionSensorModule::runOnce() { /* @@ -89,8 +99,7 @@ int32_t DetectionSensorModule::runOnce() if (!Throttle::isWithinTimespanMs(lastSentToMesh, Default::getConfiguredOrDefaultMs(moduleConfig.detection_sensor.minimum_broadcast_secs))) { bool isDetected = hasDetectionEvent(); - DetectionSensorTriggerVerdict verdict = - handlers[moduleConfig.detection_sensor.detection_trigger_type](wasDetected, isDetected); + DetectionSensorTriggerVerdict verdict = handlers[configuredTriggerType()](wasDetected, isDetected); wasDetected = isDetected; switch (verdict) { case DetectionSensorVerdictDetected: @@ -122,10 +131,14 @@ void DetectionSensorModule::sendDetectionMessage() char *message = new char[40]; sprintf(message, "%s detected", moduleConfig.detection_sensor.name); meshtastic_MeshPacket *p = allocDataPacket(); + if (!p) { + delete[] message; + return; + } p->want_ack = false; p->decoded.payload.size = strlen(message); memcpy(p->decoded.payload.bytes, message, p->decoded.payload.size); - if (moduleConfig.detection_sensor.send_bell && p->decoded.payload.size < meshtastic_Constants_DATA_PAYLOAD_LEN) { + if (moduleConfig.detection_sensor.send_bell && p->decoded.payload.size + 1 < meshtastic_Constants_DATA_PAYLOAD_LEN) { p->decoded.payload.bytes[p->decoded.payload.size] = 7; // Bell character p->decoded.payload.bytes[p->decoded.payload.size + 1] = '\0'; // Bell character p->decoded.payload.size++; @@ -144,6 +157,10 @@ void DetectionSensorModule::sendCurrentStateMessage(bool state) char *message = new char[40]; sprintf(message, "%s state: %i", moduleConfig.detection_sensor.name, state); meshtastic_MeshPacket *p = allocDataPacket(); + if (!p) { + delete[] message; + return; + } p->want_ack = false; p->decoded.payload.size = strlen(message); memcpy(p->decoded.payload.bytes, message, p->decoded.payload.size); @@ -160,5 +177,5 @@ bool DetectionSensorModule::hasDetectionEvent() { bool currentState = digitalRead(moduleConfig.detection_sensor.monitor_pin); // LOG_DEBUG("Detection Sensor Module: Current state: %i", currentState); - return (moduleConfig.detection_sensor.detection_trigger_type & 1) ? currentState : !currentState; + return (configuredTriggerType() & 1) ? currentState : !currentState; } \ No newline at end of file diff --git a/src/modules/DropzoneModule.cpp b/src/modules/DropzoneModule.cpp index 4b8f2fec8..2340f31f2 100644 --- a/src/modules/DropzoneModule.cpp +++ b/src/modules/DropzoneModule.cpp @@ -68,6 +68,8 @@ meshtastic_MeshPacket *DropzoneModule::sendConditions() // the dropzone is open auto dropzoneStatus = analogRead(A1) < 100 ? "OPEN" : "CLOSED"; auto reply = allocDataPacket(); + if (!reply) + return nullptr; auto node = nodeDB->getMeshNode(nodeDB->getNodeNum()); if (sensor.hasSensor()) { diff --git a/src/modules/KeyVerificationModule.cpp b/src/modules/KeyVerificationModule.cpp index c275ce1d9..f0a1dc8fb 100644 --- a/src/modules/KeyVerificationModule.cpp +++ b/src/modules/KeyVerificationModule.cpp @@ -6,12 +6,19 @@ #include "gps/RTC.h" #include "graphics/draw/MenuHandler.h" #include "main.h" +#include "mesh/Throttle.h" #include "meshUtils.h" #include "modules/AdminModule.h" #include "modules/NodeInfoModule.h" #include #include +#define KEY_VERIFICATION_TIMEOUT_SECS 60 +// Hard cap on one session, independent of the refreshable idle timeout above. +#define KEY_VERIFICATION_MAX_SESSION_MS (3 * 60 * 1000UL) +// Minimum spacing between remote-initiated sessions. +#define KEY_VERIFICATION_REMOTE_COOLDOWN_MS (60 * 1000UL) + KeyVerificationModule *keyVerificationModule; namespace @@ -64,7 +71,8 @@ AdminMessageHandleResult KeyVerificationModule::handleAdminMessageForModule(cons bool KeyVerificationModule::handleReceivedProtobuf(const meshtastic_MeshPacket &mp, meshtastic_KeyVerification *r) { - updateState(); + // No refresh here: this runs before the sender is checked, so any node could hold the session open. + updateState(false); // Note: pki_encrypted is not required here. The first response (M2) may arrive channel-encrypted in // the bootstrap case; the follow-on hash1 packet (M3) is required to be PKI in its branch below. if (mp.from != currentRemoteNode) { // because the inital connection request is handled in allocReply() @@ -98,6 +106,7 @@ bool KeyVerificationModule::handleReceivedProtobuf(const meshtastic_MeshPacket & service->sendClientNotification(cn); } LOG_INFO("Received hash2"); + currentNonceTimestamp = getTime(); // the protocol advanced, so extend the deadline currentState = KEY_VERIFICATION_SENDER_AWAITING_NUMBER; return true; @@ -134,6 +143,7 @@ bool KeyVerificationModule::handleReceivedProtobuf(const meshtastic_MeshPacket & service->sendClientNotification(cn); } + currentNonceTimestamp = getTime(); // the protocol advanced, so extend the deadline currentState = KEY_VERIFICATION_RECEIVER_AWAITING_USER; return true; } @@ -151,8 +161,16 @@ bool KeyVerificationModule::sendInitialRequest(NodeNum remoteNode) return false; } updateState(true); - currentNonce = random(); + // The nonce binds the handshake, so draw it from the hardware RNG (falling back to the CSPRNG) + // under cryptLock, as allocReply does for the security number. random() is both predictable and + // only 32 bits wide, leaving half of this nonce zero. + { + concurrency::LockGuard g(cryptLock); + if (!HardwareRNG::fill((uint8_t *)¤tNonce, sizeof(currentNonce))) + CryptRNG.rand((uint8_t *)¤tNonce, sizeof(currentNonce)); + } currentNonceTimestamp = getTime(); + sessionStartedMs = millis(); currentRemoteNode = remoteNode; meshtastic_KeyVerification KeyVerification = meshtastic_KeyVerification_init_zero; KeyVerification.nonce = currentNonce; @@ -162,6 +180,8 @@ bool KeyVerificationModule::sendInitialRequest(NodeNum remoteNode) KeyVerification.hash1.size = 32; memcpy(KeyVerification.hash1.bytes, owner.public_key.bytes, 32); meshtastic_MeshPacket *p = allocDataProtobuf(KeyVerification); + if (!p) + return false; p->to = remoteNode; p->channel = 0; // Only request PKI when we already hold the destination's key. Otherwise this first message goes out @@ -181,10 +201,19 @@ meshtastic_MeshPacket *KeyVerificationModule::allocReply() SHA256 hash; NodeNum ourNodeNum = nodeDB->getNodeNum(); updateState(); - if (currentState != KEY_VERIFICATION_IDLE) { // TODO: cooldown period + if (currentState != KEY_VERIFICATION_IDLE) { LOG_WARN("Key Verification requested, but already in a request"); + ignoreRequest = true; // do not let the busy path emit a NAK back to the requester return nullptr; } + // Opening a session raises a banner and locks the only slot, before the peer has authenticated. + if (lastRemoteSessionMs != 0 && Throttle::isWithinTimespanMs(lastRemoteSessionMs, KEY_VERIFICATION_REMOTE_COOLDOWN_MS)) { + LOG_WARN("Key Verification requested, but within cooldown"); + ignoreRequest = true; + return nullptr; + } + sessionStartedMs = millis(); + sessionFromRemote = true; currentState = KEY_VERIFICATION_RECEIVER_AWAITING_HASH1; auto req = *currentRequest; @@ -250,6 +279,12 @@ meshtastic_MeshPacket *KeyVerificationModule::allocReply() memcpy(response.hash2.bytes, hash2, 32); responsePacket = allocDataProtobuf(response); + if (!responsePacket) { + LOG_WARN("Key Verification response allocation failed"); + ignoreRequest = true; + resetToIdle(); + return nullptr; + } // PKI-encrypt the response only if we already held the requester's key. In the bootstrap case it goes // out channel-encrypted so the requester (who lacks our key) can decode it and read hash1. @@ -318,6 +353,8 @@ void KeyVerificationModule::processSecurityNumber(uint32_t incomingNumber) KeyVerification.hash1.size = 32; memcpy(KeyVerification.hash1.bytes, hash1, 32); meshtastic_MeshPacket *p = allocDataProtobuf(KeyVerification); + if (!p) + return; p->to = currentRemoteNode; p->channel = 0; p->pki_encrypted = true; @@ -346,11 +383,14 @@ void KeyVerificationModule::processSecurityNumber(uint32_t incomingNumber) void KeyVerificationModule::updateState(bool resetTimer) { if (currentState != KEY_VERIFICATION_IDLE) { - // check for the 60 second timeout - if (currentNonceTimestamp < getTime() - 60) { + uint32_t now = getTime(); + // Absolute cap first: it is millis() based, so it bounds the session even when the RTC is unset. + if (!Throttle::isWithinTimespanMs(sessionStartedMs, KEY_VERIFICATION_MAX_SESSION_MS)) { + resetToIdle(); + } else if (now - currentNonceTimestamp >= KEY_VERIFICATION_TIMEOUT_SECS) { resetToIdle(); } else if (resetTimer) { - currentNonceTimestamp = getTime(); + currentNonceTimestamp = now; } } } @@ -359,8 +399,12 @@ void KeyVerificationModule::resetToIdle() { memset(hash1, 0, 32); memset(hash2, 0, 32); + if (sessionFromRemote) + lastRemoteSessionMs = millis(); // start the cooldown when the session ends, not when it opened + sessionFromRemote = false; currentNonce = 0; currentNonceTimestamp = 0; + sessionStartedMs = 0; currentSecurityNumber = 0; currentRemoteNode = 0; currentState = KEY_VERIFICATION_IDLE; @@ -383,6 +427,11 @@ void KeyVerificationModule::commitVerifiedRemoteNode() if (node->public_key.size != 32 && crypto->getPendingPublicKey(currentRemoteNode, pending)) node->public_key = pending; node->bitfield |= NODEINFO_BITFIELD_IS_KEY_MANUALLY_VERIFIED_MASK; + // Re-commit via the bare-key primitive: writing the same bytes back is a no-op for the hot + // store, but it routes the TrafficManagement write-through. ManuallyVerified: the user just + // confirmed possession of exactly this key - the strongest provenance that cache can carry. + if (node->public_key.size == 32) + nodeDB->commitRemoteKey(currentRemoteNode, node->public_key.bytes, NodeDB::KeyCommitTrust::ManuallyVerified); LOG_INFO("Node 0x%08x manually verified with security number %u", currentRemoteNode, currentSecurityNumber); if (nodeInfoModule) nodeInfoModule->sendOurNodeInfo(currentRemoteNode, false, node->channel, true); diff --git a/src/modules/KeyVerificationModule.h b/src/modules/KeyVerificationModule.h index 9496649d3..d33a89c5e 100644 --- a/src/modules/KeyVerificationModule.h +++ b/src/modules/KeyVerificationModule.h @@ -84,6 +84,12 @@ class KeyVerificationModule : public ProtobufModule private: uint64_t currentNonce = 0; uint32_t currentNonceTimestamp = 0; + // millis() the session opened, never refreshed, so a peer cannot hold the slot open indefinitely. + uint32_t sessionStartedMs = 0; + // millis() a remote-initiated session last ended. Spacing sessions bounds the slot DoS and the + // banner spam; stamped at the end rather than the start so the cooldown is a real gap. + uint32_t lastRemoteSessionMs = 0; + bool sessionFromRemote = false; NodeNum currentRemoteNode = 0; uint32_t currentSecurityNumber = 0; KeyVerificationState currentState = KEY_VERIFICATION_IDLE; diff --git a/src/modules/NeighborInfoModule.cpp b/src/modules/NeighborInfoModule.cpp index a626bbcaa..f42194a81 100644 --- a/src/modules/NeighborInfoModule.cpp +++ b/src/modules/NeighborInfoModule.cpp @@ -110,6 +110,8 @@ void NeighborInfoModule::sendNeighborInfo(NodeNum dest, bool wantReplies) // only send neighbours if we have some to send if (neighborInfo.neighbors_count > 0) { meshtastic_MeshPacket *p = allocDataProtobuf(neighborInfo); + if (!p) + return; p->to = dest; p->decoded.want_response = wantReplies; p->priority = meshtastic_MeshPacket_Priority_BACKGROUND; diff --git a/src/modules/NodeInfoModule.cpp b/src/modules/NodeInfoModule.cpp index 0e40daf10..5b8d46223 100644 --- a/src/modules/NodeInfoModule.cpp +++ b/src/modules/NodeInfoModule.cpp @@ -61,7 +61,9 @@ bool NodeInfoModule::handleReceivedProtobuf(const meshtastic_MeshPacket &mp, mes // Coerce user.id to be derived from the node number snprintf(p.id, sizeof(p.id), "!%08x", getFrom(&mp)); - bool hasChanged = nodeDB->updateUser(getFrom(&mp), p, mp.channel); + // updateUser() refuses the identity write for a known signer sending unsigned (all unicast + // NodeInfo), so the exchange above still proceeds but cannot spoof the stored name. + bool hasChanged = nodeDB->updateUser(getFrom(&mp), p, mp.channel, mp.xeddsa_signed); bool wasBroadcast = isBroadcast(mp.to); @@ -167,14 +169,8 @@ meshtastic_MeshPacket *NodeInfoModule::allocReply() ignoreRequest = true; return NULL; } else { - ignoreRequest = false; // Don't ignore requests anymore - meshtastic_User u = owner; // deliberate copy: the licensed strip below must not clobber the global owner state - - // Strip the public key if the user is licensed - if (u.is_licensed && u.public_key.size > 0) { - memset(u.public_key.bytes, 0, sizeof(u.public_key.bytes)); - u.public_key.size = 0; - } + ignoreRequest = false; // Don't ignore requests anymore + meshtastic_User u = owner; // FIXME: Clear the user.id field since it should be derived from node number on the receiving end // u.id[0] = '\0'; @@ -232,4 +228,4 @@ int32_t NodeInfoModule::runOnce() sendOurNodeInfo(NODENUM_BROADCAST, requestReplies); // Send our info (don't request replies) } return Default::getConfiguredOrDefaultMs(config.device.node_info_broadcast_secs, default_node_info_broadcast_secs); -} \ No newline at end of file +} diff --git a/src/modules/OnScreenKeyboardModule.cpp b/src/modules/OnScreenKeyboardModule.cpp index e75d926bf..ae2707cfe 100644 --- a/src/modules/OnScreenKeyboardModule.cpp +++ b/src/modules/OnScreenKeyboardModule.cpp @@ -65,7 +65,6 @@ void OnScreenKeyboardModule::stop(bool callEmptyCallback) // Keep NotificationRenderer legacy pointers in sync NotificationRenderer::virtualKeyboard = nullptr; NotificationRenderer::textInputCallback = nullptr; - clearPopup(); if (callEmptyCallback && cb) cb(""); } @@ -131,9 +130,6 @@ bool OnScreenKeyboardModule::draw(OLEDDisplay *display) display->fillRect(0, 0, display->getWidth(), display->getHeight()); display->setColor(WHITE); keyboard->draw(display, 0, 0); - - // Draw popup overlay if needed - drawPopup(display); return true; } @@ -150,123 +146,6 @@ void OnScreenKeyboardModule::onCancel() stop(true); } -void OnScreenKeyboardModule::showPopup(const char *title, const char *content, uint32_t durationMs) -{ - if (!title || !content) - return; - strncpy(popupTitle, title, sizeof(popupTitle) - 1); - popupTitle[sizeof(popupTitle) - 1] = '\0'; - strncpy(popupMessage, content, sizeof(popupMessage) - 1); - popupMessage[sizeof(popupMessage) - 1] = '\0'; - popupUntil = millis() + durationMs; - popupVisible = true; -} - -void OnScreenKeyboardModule::clearPopup() -{ - popupTitle[0] = '\0'; - popupMessage[0] = '\0'; - popupUntil = 0; - popupVisible = false; -} - -void OnScreenKeyboardModule::drawPopupOverlay(OLEDDisplay *display) -{ - // Only render the popup overlay (without drawing the keyboard) - drawPopup(display); -} - -void OnScreenKeyboardModule::drawPopup(OLEDDisplay *display) -{ - if (!popupVisible) - return; - if (millis() > popupUntil || popupMessage[0] == '\0') { - popupVisible = false; - return; - } - - // Build lines and leverage NotificationRenderer inverted box drawing for consistent style - constexpr uint16_t maxContentLines = 3; - const bool hasTitle = popupTitle[0] != '\0'; - - display->setFont(FONT_SMALL); - display->setTextAlignment(TEXT_ALIGN_LEFT); - const uint16_t maxWrapWidth = display->width() - 40; - - auto wrapText = [&](const char *text, uint16_t availableWidth) -> std::vector { - std::vector wrapped; - std::string current; - std::string word; - const char *p = text; - while (*p && wrapped.size() < maxContentLines) { - while (*p && (*p == ' ' || *p == '\t' || *p == '\n' || *p == '\r')) { - if (*p == '\n') { - if (!current.empty()) { - wrapped.push_back(current); - current.clear(); - if (wrapped.size() >= maxContentLines) - break; - } - } - ++p; - } - if (!*p || wrapped.size() >= maxContentLines) - break; - word.clear(); - while (*p && *p != ' ' && *p != '\t' && *p != '\n' && *p != '\r') - word += *p++; - if (word.empty()) - continue; - std::string test = current.empty() ? word : (current + " " + word); - uint16_t w = display->getStringWidth(test.c_str(), test.length(), true); - if (w <= availableWidth) - current = test; - else { - if (!current.empty()) { - wrapped.push_back(current); - current = word; - if (wrapped.size() >= maxContentLines) - break; - } else { - current = word; - while (current.size() > 1 && - display->getStringWidth(current.c_str(), current.length(), true) > availableWidth) - current.pop_back(); - } - } - } - if (!current.empty() && wrapped.size() < maxContentLines) - wrapped.push_back(current); - return wrapped; - }; - - std::vector allLines; - if (hasTitle) - allLines.emplace_back(popupTitle); - - char buf[sizeof(popupMessage)]; - strncpy(buf, popupMessage, sizeof(buf) - 1); - buf[sizeof(buf) - 1] = '\0'; - char *paragraph = strtok(buf, "\n"); - while (paragraph && allLines.size() < maxContentLines + (hasTitle ? 1 : 0)) { - auto wrapped = wrapText(paragraph, maxWrapWidth); - for (const auto &ln : wrapped) { - if (allLines.size() >= maxContentLines + (hasTitle ? 1 : 0)) - break; - allLines.push_back(ln); - } - paragraph = strtok(nullptr, "\n"); - } - - std::vector ptrs; - for (const auto &ln : allLines) - ptrs.push_back(ln.c_str()); - ptrs.push_back(nullptr); - - // Use the standard notification box drawing from NotificationRenderer - NotificationRenderer::drawNotificationBox(display, nullptr, ptrs.data(), allLines.size(), 0, 0); -} - } // namespace graphics #endif // HAS_SCREEN diff --git a/src/modules/OnScreenKeyboardModule.h b/src/modules/OnScreenKeyboardModule.h index f86b71ec3..40dc23fae 100644 --- a/src/modules/OnScreenKeyboardModule.h +++ b/src/modules/OnScreenKeyboardModule.h @@ -25,11 +25,6 @@ class OnScreenKeyboardModule static bool processVirtualKeyboardInput(const InputEvent &event, VirtualKeyboard *keyboard); bool draw(OLEDDisplay *display); - void showPopup(const char *title, const char *content, uint32_t durationMs); - void clearPopup(); - // Draw only the popup overlay (used when legacy virtualKeyboard draws the keyboard) - void drawPopupOverlay(OLEDDisplay *display); - private: OnScreenKeyboardModule() = default; ~OnScreenKeyboardModule(); @@ -39,15 +34,8 @@ class OnScreenKeyboardModule void onSubmit(const std::string &text); void onCancel(); - void drawPopup(OLEDDisplay *display); - VirtualKeyboard *keyboard = nullptr; std::function callback; - - char popupTitle[64] = {0}; - char popupMessage[256] = {0}; - uint32_t popupUntil = 0; - bool popupVisible = false; }; } // namespace graphics diff --git a/src/modules/PositionModule.cpp b/src/modules/PositionModule.cpp index 18931da12..40055006a 100644 --- a/src/modules/PositionModule.cpp +++ b/src/modules/PositionModule.cpp @@ -292,6 +292,8 @@ meshtastic_MeshPacket *PositionModule::allocAtakPli() { LOG_INFO("Send TAK V2 PLI packet"); meshtastic_MeshPacket *mp = allocDataPacket(); + if (!mp) + return nullptr; mp->decoded.portnum = meshtastic_PortNum_ATAK_PLUGIN_V2; meshtastic_TAKPacketV2 takPacket = meshtastic_TAKPacketV2_init_zero; @@ -572,6 +574,8 @@ int32_t PositionModule::runOnce() void PositionModule::sendLostAndFoundText() { meshtastic_MeshPacket *p = allocDataPacket(); + if (!p) + return; p->to = NODENUM_BROADCAST; char message[128]; int written = snprintf(message, sizeof(message), "๐ŸšจI'm lost! Lat / Lon: %f, %f\a", (lastGpsLatitude * 1e-7), diff --git a/src/modules/RangeTestModule.cpp b/src/modules/RangeTestModule.cpp index a02f0594e..2ca589131 100644 --- a/src/modules/RangeTestModule.cpp +++ b/src/modules/RangeTestModule.cpp @@ -8,15 +8,16 @@ * The RangeTestModule class is an OSThread that runs the module. * The RangeTestModuleRadio class handles sending and receiving packets. */ -#include "RangeTestModule.h" +#include "configuration.h" +#if !MESHTASTIC_EXCLUDE_RANGETEST && !MESHTASTIC_EXCLUDE_GPS #include "FSCommon.h" #include "MeshService.h" #include "NodeDB.h" #include "PowerFSM.h" +#include "RangeTestModule.h" #include "Router.h" #include "SPILock.h" #include "airtime.h" -#include "configuration.h" #include "gps/GeoCoord.h" #include "gps/RTC.h" #include @@ -114,6 +115,8 @@ int32_t RangeTestModule::runOnce() void RangeTestModuleRadio::sendPayload(NodeNum dest, bool wantReplies) { meshtastic_MeshPacket *p = allocDataPacket(); + if (!p) + return; p->to = dest; p->decoded.want_response = wantReplies; p->hop_limit = 0; @@ -351,3 +354,5 @@ bool RangeTestModuleRadio::removeFile() return 0; #endif } + +#endif // !MESHTASTIC_EXCLUDE_RANGETEST && !MESHTASTIC_EXCLUDE_GPS diff --git a/src/modules/ReplyBotModule.cpp b/src/modules/ReplyBotModule.cpp index 3f8788735..52934c800 100644 --- a/src/modules/ReplyBotModule.cpp +++ b/src/modules/ReplyBotModule.cpp @@ -168,6 +168,8 @@ void ReplyBotModule::sendDm(const meshtastic_MeshPacket &rx, const char *text) if (!text) return; meshtastic_MeshPacket *p = allocDataPacket(); + if (!p) + return; p->to = rx.from; p->channel = rx.channel; p->want_ack = false; diff --git a/src/modules/ReplyModule.cpp b/src/modules/ReplyModule.cpp index c48b16d53..7a97c0c68 100644 --- a/src/modules/ReplyModule.cpp +++ b/src/modules/ReplyModule.cpp @@ -16,7 +16,9 @@ meshtastic_MeshPacket *ReplyModule::allocReply() #endif const char *replyStr = "Message Received"; - auto reply = allocDataPacket(); // Allocate a packet for sending + auto reply = allocDataPacket(); // Allocate a packet for sending + if (!reply) + return nullptr; reply->decoded.payload.size = strlen(replyStr); // You must specify how many bytes are in the reply memcpy(reply->decoded.payload.bytes, replyStr, reply->decoded.payload.size); diff --git a/src/modules/RoutingModule.cpp b/src/modules/RoutingModule.cpp index a46323ac6..1ce7c4502 100644 --- a/src/modules/RoutingModule.cpp +++ b/src/modules/RoutingModule.cpp @@ -51,11 +51,14 @@ void RoutingModule::sendAckNak(meshtastic_Routing_Error err, NodeNum to, PacketI bool ackWantsAck) { auto p = allocAckNak(err, to, idFrom, chIndex, hopLimit); + if (!p) + return; // Allow the caller to set want_ack on this ACK packet if it's important that the ACK be delivered reliably p->want_ack = ackWantsAck; - router->sendLocal(p); // we sometimes send directly to the local node + if (router->sendLocal(p) == ERRNO_SHOULD_RELEASE) // we sometimes send directly to the local node + packetPool.release(p); } uint8_t RoutingModule::getHopLimitForResponse(const meshtastic_MeshPacket &mp) diff --git a/src/modules/SerialModule.cpp b/src/modules/SerialModule.cpp index aab79ffd5..cb481e6a5 100644 --- a/src/modules/SerialModule.cpp +++ b/src/modules/SerialModule.cpp @@ -109,7 +109,7 @@ bool SerialModule::isValidConfig(const meshtastic_ModuleConfig_SerialConfig &con return true; } -SerialModuleRadio::SerialModuleRadio() : MeshModule("SerialModuleRadio") +SerialModuleRadio::SerialModuleRadio() : SinglePortModule("SerialModuleRadio", meshtastic_PortNum_SERIAL_APP) { switch (moduleConfig.serial.mode) { case meshtastic_ModuleConfig_SerialConfig_Serial_Mode_TEXTMSG: @@ -314,6 +314,8 @@ int32_t SerialModule::runOnce() void SerialModule::sendTelemetry(meshtastic_Telemetry m) { meshtastic_MeshPacket *p = router->allocForSending(); + if (!p) + return; p->decoded.portnum = meshtastic_PortNum_TELEMETRY_APP; p->decoded.payload.size = pb_encode_to_bytes(p->decoded.payload.bytes, sizeof(p->decoded.payload.bytes), &meshtastic_Telemetry_msg, &m); @@ -328,18 +330,6 @@ void SerialModule::sendTelemetry(meshtastic_Telemetry m) service->sendToMesh(p, RX_SRC_LOCAL, true); } -/** - * Allocates a new mesh packet for use as a reply to a received packet. - * - * @return A pointer to the newly allocated mesh packet. - */ -meshtastic_MeshPacket *SerialModuleRadio::allocReply() -{ - auto reply = allocDataPacket(); // Allocate a packet for sending - - return reply; -} - /** * Sends a payload to a specified destination node. * @@ -349,7 +339,9 @@ meshtastic_MeshPacket *SerialModuleRadio::allocReply() void SerialModuleRadio::sendPayload(NodeNum dest, bool wantReplies) { const meshtastic_Channel *ch = (boundChannel != NULL) ? &channels.getByName(boundChannel) : NULL; - meshtastic_MeshPacket *p = allocReply(); + meshtastic_MeshPacket *p = allocDataPacket(); + if (!p) + return; p->to = dest; if (ch != NULL) { p->channel = ch->index; diff --git a/src/modules/SerialModule.h b/src/modules/SerialModule.h index dbe4f75db..5cbca7824 100644 --- a/src/modules/SerialModule.h +++ b/src/modules/SerialModule.h @@ -40,7 +40,7 @@ extern SerialModule *serialModule; * Radio interface for SerialModule * */ -class SerialModuleRadio : public MeshModule +class SerialModuleRadio : public SinglePortModule { uint32_t lastRxID = 0; char outbuf[90] = ""; @@ -54,29 +54,14 @@ class SerialModuleRadio : public MeshModule void sendPayload(NodeNum dest = NODENUM_BROADCAST, bool wantReplies = false); protected: - virtual meshtastic_MeshPacket *allocReply() override; - /** Called to handle a particular incoming message @return ProcessMessage::STOP if you've guaranteed you've handled this message and no other handlers should be considered for it */ virtual ProcessMessage handleReceived(const meshtastic_MeshPacket &mp) override; - - meshtastic_PortNum ourPortNum; - - virtual bool wantPacket(const meshtastic_MeshPacket *p) override { return p->decoded.portnum == ourPortNum; } - - meshtastic_MeshPacket *allocDataPacket() - { - // Update our local node info with our position (even if we don't decide to update anyone else) - meshtastic_MeshPacket *p = router->allocForSending(); - p->decoded.portnum = ourPortNum; - - return p; - } }; extern SerialModuleRadio *serialModuleRadio; -#endif \ No newline at end of file +#endif diff --git a/src/modules/StatusMessageModule.cpp b/src/modules/StatusMessageModule.cpp index 2f05517f3..bf4badc4b 100644 --- a/src/modules/StatusMessageModule.cpp +++ b/src/modules/StatusMessageModule.cpp @@ -15,6 +15,8 @@ int32_t StatusMessageModule::runOnce() strncpy(ourStatus.status, moduleConfig.statusmessage.node_status, sizeof(ourStatus.status)); ourStatus.status[sizeof(ourStatus.status) - 1] = '\0'; // ensure null termination meshtastic_MeshPacket *p = allocDataPacket(); + if (!p) + return 1000 * 12 * 60 * 60; p->decoded.payload.size = pb_encode_to_bytes(p->decoded.payload.bytes, sizeof(p->decoded.payload.bytes), meshtastic_StatusMessage_fields, &ourStatus); p->to = NODENUM_BROADCAST; diff --git a/src/modules/StoreForwardModule.cpp b/src/modules/StoreForwardModule.cpp index 3254d11a3..de1d6865c 100644 --- a/src/modules/StoreForwardModule.cpp +++ b/src/modules/StoreForwardModule.cpp @@ -248,6 +248,8 @@ meshtastic_MeshPacket *StoreForwardModule::preparePayload(NodeNum dest, uint32_t (this->packetHistory[i].to == NODENUM_BROADCAST || this->packetHistory[i].to == dest)) { meshtastic_MeshPacket *p = allocDataPacket(); + if (!p) + return nullptr; p->to = local ? this->packetHistory[i].to : dest; // PhoneAPI can handle original `to` p->from = this->packetHistory[i].from; @@ -304,6 +306,8 @@ meshtastic_MeshPacket *StoreForwardModule::preparePayload(NodeNum dest, uint32_t void StoreForwardModule::sendMessage(NodeNum dest, const meshtastic_StoreAndForward &payload) { meshtastic_MeshPacket *p = allocDataProtobuf(payload); + if (!p) + return; p->to = dest; @@ -340,6 +344,8 @@ void StoreForwardModule::sendMessage(NodeNum dest, meshtastic_StoreAndForward_Re void StoreForwardModule::sendErrorTextMessage(NodeNum dest, bool want_response) { meshtastic_MeshPacket *pr = allocDataPacket(); + if (!pr) + return; pr->to = dest; pr->priority = meshtastic_MeshPacket_Priority_BACKGROUND; pr->want_ack = false; diff --git a/src/modules/Telemetry/AirQualityTelemetry.cpp b/src/modules/Telemetry/AirQualityTelemetry.cpp index 7ab0ed3d4..9ea8d9e39 100644 --- a/src/modules/Telemetry/AirQualityTelemetry.cpp +++ b/src/modules/Telemetry/AirQualityTelemetry.cpp @@ -464,35 +464,39 @@ bool AirQualityTelemetryModule::sendTelemetry(NodeNum dest, bool phoneOnly) } meshtastic_MeshPacket *p = allocDataProtobuf(m); - p->to = dest; - p->decoded.want_response = false; - if (config.device.role == meshtastic_Config_DeviceConfig_Role_SENSOR) - p->priority = meshtastic_MeshPacket_Priority_RELIABLE; - else - p->priority = meshtastic_MeshPacket_Priority_BACKGROUND; - - // release previous packet before occupying a new spot - if (lastMeasurementPacket != nullptr) - packetPool.release(lastMeasurementPacket); - - lastMeasurementPacket = packetPool.allocCopy(*p); - if (phoneOnly) { - LOG_INFO("Sending packet to phone"); - service->sendToPhone(p); + if (!p) { + validTelemetry = false; } else { - LOG_INFO("Sending packet to mesh"); - service->sendToMesh(p, RX_SRC_LOCAL, true); + p->to = dest; + p->decoded.want_response = false; + if (config.device.role == meshtastic_Config_DeviceConfig_Role_SENSOR) + p->priority = meshtastic_MeshPacket_Priority_RELIABLE; + else + p->priority = meshtastic_MeshPacket_Priority_BACKGROUND; - if (isPowerSavingSensor()) { - meshtastic_ClientNotification *notification = clientNotificationPool.allocZeroed(); - if (notification) { - notification->level = meshtastic_LogRecord_Level_INFO; - notification->time = getValidTime(RTCQualityFromNet); - sprintf(notification->message, "Sending telemetry and sleeping for %us interval in a moment", - Default::getConfiguredOrDefaultMs(moduleConfig.telemetry.air_quality_interval, - default_telemetry_broadcast_interval_secs) / - 1000U); - service->sendClientNotification(notification); + // release previous packet before occupying a new spot + if (lastMeasurementPacket != nullptr) + packetPool.release(lastMeasurementPacket); + + lastMeasurementPacket = packetPool.allocCopy(*p); + if (phoneOnly) { + LOG_INFO("Sending packet to phone"); + service->sendToPhone(p); + } else { + LOG_INFO("Sending packet to mesh"); + service->sendToMesh(p, RX_SRC_LOCAL, true); + + if (isPowerSavingSensor()) { + meshtastic_ClientNotification *notification = clientNotificationPool.allocZeroed(); + if (notification) { + notification->level = meshtastic_LogRecord_Level_INFO; + notification->time = getValidTime(RTCQualityFromNet); + sprintf(notification->message, "Sending telemetry and sleeping for %us interval in a moment", + Default::getConfiguredOrDefaultMs(moduleConfig.telemetry.air_quality_interval, + default_telemetry_broadcast_interval_secs) / + 1000U); + service->sendClientNotification(notification); + } } } } diff --git a/src/modules/Telemetry/DeviceTelemetry.cpp b/src/modules/Telemetry/DeviceTelemetry.cpp index d8f17963d..7ae6ac615 100644 --- a/src/modules/Telemetry/DeviceTelemetry.cpp +++ b/src/modules/Telemetry/DeviceTelemetry.cpp @@ -172,6 +172,8 @@ meshtastic_Telemetry DeviceTelemetryModule::getLocalStatsTelemetry() void DeviceTelemetryModule::sendLocalStatsToPhone() { meshtastic_MeshPacket *p = allocDataProtobuf(getLocalStatsTelemetry()); + if (!p) + return; p->to = NODENUM_BROADCAST; p->decoded.want_response = false; p->priority = meshtastic_MeshPacket_Priority_BACKGROUND; @@ -191,6 +193,8 @@ bool DeviceTelemetryModule::sendTelemetry(NodeNum dest, bool phoneOnly) meshtastic_MeshPacket *p = allocDataProtobuf(telemetry); DEBUG_HEAP_AFTER("DeviceTelemetryModule::sendTelemetry", p); + if (!p) + return false; p->to = dest; p->decoded.want_response = false; p->priority = meshtastic_MeshPacket_Priority_BACKGROUND; diff --git a/src/modules/Telemetry/EnvironmentTelemetry.cpp b/src/modules/Telemetry/EnvironmentTelemetry.cpp index 63273239d..659ed3278 100644 --- a/src/modules/Telemetry/EnvironmentTelemetry.cpp +++ b/src/modules/Telemetry/EnvironmentTelemetry.cpp @@ -661,34 +661,38 @@ bool EnvironmentTelemetryModule::sendTelemetry(NodeNum dest, bool phoneOnly) m.variant.environment_metrics.soil_moisture); meshtastic_MeshPacket *p = allocDataProtobuf(m); - p->to = dest; - p->decoded.want_response = false; - if (config.device.role == meshtastic_Config_DeviceConfig_Role_SENSOR) - p->priority = meshtastic_MeshPacket_Priority_RELIABLE; - else - p->priority = meshtastic_MeshPacket_Priority_BACKGROUND; - // release previous packet before occupying a new spot - if (lastMeasurementPacket != nullptr) - packetPool.release(lastMeasurementPacket); - - lastMeasurementPacket = packetPool.allocCopy(*p); - if (phoneOnly) { - LOG_INFO("Send packet to phone"); - service->sendToPhone(p); + if (!p) { + validTelemetry = false; } else { - LOG_INFO("Send packet to mesh"); - service->sendToMesh(p, RX_SRC_LOCAL, true); + p->to = dest; + p->decoded.want_response = false; + if (config.device.role == meshtastic_Config_DeviceConfig_Role_SENSOR) + p->priority = meshtastic_MeshPacket_Priority_RELIABLE; + else + p->priority = meshtastic_MeshPacket_Priority_BACKGROUND; + // release previous packet before occupying a new spot + if (lastMeasurementPacket != nullptr) + packetPool.release(lastMeasurementPacket); - if (isPowerSavingSensor()) { - meshtastic_ClientNotification *notification = clientNotificationPool.allocZeroed(); - if (notification) { - notification->level = meshtastic_LogRecord_Level_INFO; - notification->time = getValidTime(RTCQualityFromNet); - sprintf(notification->message, "Sending telemetry and sleeping for %us interval in a moment", - Default::getConfiguredOrDefaultMs(moduleConfig.telemetry.environment_update_interval, - default_telemetry_broadcast_interval_secs) / - 1000U); - service->sendClientNotification(notification); + lastMeasurementPacket = packetPool.allocCopy(*p); + if (phoneOnly) { + LOG_INFO("Send packet to phone"); + service->sendToPhone(p); + } else { + LOG_INFO("Send packet to mesh"); + service->sendToMesh(p, RX_SRC_LOCAL, true); + + if (isPowerSavingSensor()) { + meshtastic_ClientNotification *notification = clientNotificationPool.allocZeroed(); + if (notification) { + notification->level = meshtastic_LogRecord_Level_INFO; + notification->time = getValidTime(RTCQualityFromNet); + sprintf(notification->message, "Sending telemetry and sleeping for %us interval in a moment", + Default::getConfiguredOrDefaultMs(moduleConfig.telemetry.environment_update_interval, + default_telemetry_broadcast_interval_secs) / + 1000U); + service->sendClientNotification(notification); + } } } } diff --git a/src/modules/Telemetry/HealthTelemetry.cpp b/src/modules/Telemetry/HealthTelemetry.cpp index f68c92e1b..944bc4db4 100644 --- a/src/modules/Telemetry/HealthTelemetry.cpp +++ b/src/modules/Telemetry/HealthTelemetry.cpp @@ -248,23 +248,27 @@ bool HealthTelemetryModule::sendTelemetry(NodeNum dest, bool phoneOnly) sensor_read_error_count = 0; meshtastic_MeshPacket *p = allocDataProtobuf(m); - p->to = dest; - p->decoded.want_response = false; - if (config.device.role == meshtastic_Config_DeviceConfig_Role_SENSOR) - p->priority = meshtastic_MeshPacket_Priority_RELIABLE; - else - p->priority = meshtastic_MeshPacket_Priority_BACKGROUND; - // release previous packet before occupying a new spot - if (lastMeasurementPacket != nullptr) - packetPool.release(lastMeasurementPacket); - - lastMeasurementPacket = packetPool.allocCopy(*p); - if (phoneOnly) { - LOG_INFO("Send packet to phone"); - service->sendToPhone(p); + if (!p) { + validTelemetry = false; } else { - LOG_INFO("Send packet to mesh"); - service->sendToMesh(p, RX_SRC_LOCAL, true); + p->to = dest; + p->decoded.want_response = false; + if (config.device.role == meshtastic_Config_DeviceConfig_Role_SENSOR) + p->priority = meshtastic_MeshPacket_Priority_RELIABLE; + else + p->priority = meshtastic_MeshPacket_Priority_BACKGROUND; + // release previous packet before occupying a new spot + if (lastMeasurementPacket != nullptr) + packetPool.release(lastMeasurementPacket); + + lastMeasurementPacket = packetPool.allocCopy(*p); + if (phoneOnly) { + LOG_INFO("Send packet to phone"); + service->sendToPhone(p); + } else { + LOG_INFO("Send packet to mesh"); + service->sendToMesh(p, RX_SRC_LOCAL, true); + } } } diff --git a/src/modules/Telemetry/HostMetrics.cpp b/src/modules/Telemetry/HostMetrics.cpp index 577132006..a9490bc10 100644 --- a/src/modules/Telemetry/HostMetrics.cpp +++ b/src/modules/Telemetry/HostMetrics.cpp @@ -128,6 +128,8 @@ bool HostMetricsModule::sendMetrics() // telemetry.variant.host_metrics.has_user_string ? telemetry.variant.host_metrics.user_string : ""); meshtastic_MeshPacket *p = allocDataProtobuf(telemetry); + if (!p) + return false; p->to = NODENUM_BROADCAST; p->decoded.want_response = false; p->priority = meshtastic_MeshPacket_Priority_BACKGROUND; diff --git a/src/modules/Telemetry/PowerTelemetry.cpp b/src/modules/Telemetry/PowerTelemetry.cpp index 816f02898..23ef56ba8 100644 --- a/src/modules/Telemetry/PowerTelemetry.cpp +++ b/src/modules/Telemetry/PowerTelemetry.cpp @@ -275,23 +275,27 @@ bool PowerTelemetryModule::sendTelemetry(NodeNum dest, bool phoneOnly) sensor_read_error_count = 0; meshtastic_MeshPacket *p = allocDataProtobuf(m); - p->to = dest; - p->decoded.want_response = false; - if (config.device.role == meshtastic_Config_DeviceConfig_Role_SENSOR) - p->priority = meshtastic_MeshPacket_Priority_RELIABLE; - else - p->priority = meshtastic_MeshPacket_Priority_BACKGROUND; - // release previous packet before occupying a new spot - if (lastMeasurementPacket != nullptr) - packetPool.release(lastMeasurementPacket); - - lastMeasurementPacket = packetPool.allocCopy(*p); - if (phoneOnly) { - LOG_INFO("Send packet to phone"); - service->sendToPhone(p); + if (!p) { + validTelemetry = false; } else { - LOG_INFO("Send packet to mesh"); - service->sendToMesh(p, RX_SRC_LOCAL, true); + p->to = dest; + p->decoded.want_response = false; + if (config.device.role == meshtastic_Config_DeviceConfig_Role_SENSOR) + p->priority = meshtastic_MeshPacket_Priority_RELIABLE; + else + p->priority = meshtastic_MeshPacket_Priority_BACKGROUND; + // release previous packet before occupying a new spot + if (lastMeasurementPacket != nullptr) + packetPool.release(lastMeasurementPacket); + + lastMeasurementPacket = packetPool.allocCopy(*p); + if (phoneOnly) { + LOG_INFO("Send packet to phone"); + service->sendToPhone(p); + } else { + LOG_INFO("Send packet to mesh"); + service->sendToMesh(p, RX_SRC_LOCAL, true); + } } } diff --git a/src/modules/Telemetry/Sensor/SCD4XSensor.cpp b/src/modules/Telemetry/Sensor/SCD4XSensor.cpp index bae0cf12a..26d50ab3e 100644 --- a/src/modules/Telemetry/Sensor/SCD4XSensor.cpp +++ b/src/modules/Telemetry/Sensor/SCD4XSensor.cpp @@ -104,8 +104,18 @@ bool SCD4XSensor::getMetrics(meshtastic_Telemetry *measurement) reClockI2C.setClock(SCD4X_I2C_CLOCK_SPEED); #endif /* SCD4X_I2C_CLOCK_SPEED */ - bool dataReady; - error = scd4x.getDataReadyStatus(dataReady); + bool dataReady = false; + uint8_t dataReadyTries = 0; + + while (!dataReady && (dataReadyTries < SCD4X_MAX_RETRIES)) { + error = scd4x.getDataReadyStatus(dataReady); + if (error != SCD4X_NO_ERROR || !dataReady) { + LOG_WARN("%s: Error collecting data. Retrying", sensorName); + delay(100); + dataReadyTries++; + } + } + if (error != SCD4X_NO_ERROR || !dataReady) { #ifdef SCD4X_I2C_CLOCK_SPEED LOG_DEBUG("%s: restoring clock speed", sensorName); diff --git a/src/modules/Telemetry/Sensor/SCD4XSensor.h b/src/modules/Telemetry/Sensor/SCD4XSensor.h index 065c82a13..f9161942e 100644 --- a/src/modules/Telemetry/Sensor/SCD4XSensor.h +++ b/src/modules/Telemetry/Sensor/SCD4XSensor.h @@ -11,6 +11,7 @@ // Max speed 400kHz #define SCD4X_I2C_CLOCK_SPEED 400000 #define SCD4X_WARMUP_MS 5000 +#define SCD4X_MAX_RETRIES 3 class SCD4XSensor : public TelemetrySensor { diff --git a/src/modules/Telemetry/UnitConversions.cpp b/src/modules/Telemetry/UnitConversions.cpp index fff1ee3d2..476ec7252 100644 --- a/src/modules/Telemetry/UnitConversions.cpp +++ b/src/modules/Telemetry/UnitConversions.cpp @@ -10,11 +10,6 @@ float UnitConversions::MetersPerSecondToKnots(float metersPerSecond) return metersPerSecond * 1.94384; } -float UnitConversions::MetersPerSecondToMilesPerHour(float metersPerSecond) -{ - return metersPerSecond * 2.23694; -} - float UnitConversions::HectoPascalToInchesOfMercury(float hectoPascal) { return hectoPascal * 0.029529983071445; diff --git a/src/modules/Telemetry/UnitConversions.h b/src/modules/Telemetry/UnitConversions.h index 00d0bd51b..9819d284f 100644 --- a/src/modules/Telemetry/UnitConversions.h +++ b/src/modules/Telemetry/UnitConversions.h @@ -7,7 +7,6 @@ class UnitConversions public: static float CelsiusToFahrenheit(float celsius); static float MetersPerSecondToKnots(float metersPerSecond); - static float MetersPerSecondToMilesPerHour(float metersPerSecond); static float HectoPascalToInchesOfMercury(float hectoPascal); // Bound a float before Arduino String(float) renders it: its fixed char[33] + dtostrf overflow diff --git a/src/modules/TextMessageModule.cpp b/src/modules/TextMessageModule.cpp index 1d0912311..818e39a94 100644 --- a/src/modules/TextMessageModule.cpp +++ b/src/modules/TextMessageModule.cpp @@ -25,12 +25,14 @@ ProcessMessage TextMessageModule::handleReceived(const meshtastic_MeshPacket &mp // Guard against running in MeshtasticUI or with no screen if (config.display.displaymode != meshtastic_Config_DisplayConfig_DisplayMode_COLOR) { // Store in the central message history - const StoredMessage &sm = messageStore.addFromPacket(mp); + const StoredMessage *sm = messageStore.tryAddFromPacket(mp); + if (!sm) + return ProcessMessage::CONTINUE; // Pass message to renderer (banner + thread switching + scroll reset) // Use the global Screen singleton to retrieve the current OLED display auto *display = screen ? screen->getDisplayDevice() : nullptr; - graphics::MessageRenderer::handleNewMessage(display, sm, mp); + graphics::MessageRenderer::handleNewMessage(display, *sm, mp); }) // Only trigger screen wake if configuration allows it if (shouldWakeOnReceivedMessage()) { diff --git a/src/modules/TraceRouteModule.cpp b/src/modules/TraceRouteModule.cpp index 11019dcf9..c0cac7123 100644 --- a/src/modules/TraceRouteModule.cpp +++ b/src/modules/TraceRouteModule.cpp @@ -12,7 +12,7 @@ #include "modules/TrafficManagementModule.h" #endif -extern graphics::Screen *screen; +extern std::unique_ptr screen; TraceRouteModule *traceRouteModule; @@ -305,6 +305,15 @@ void TraceRouteModule::updateNextHops(const meshtastic_MeshPacket &p, meshtastic } uint8_t nextHopByte = nodeDB->getLastByteOfNodeNum(nextHop); + // The route array is unauthenticated payload, so only learn from it when the node it names as our + // next hop is the one that actually relayed this packet to us. Otherwise a forged response could + // point any node's next_hop anywhere. relay_node is 0 for MQTT-sourced packets, which cannot + // corroborate an RF route either. + if (p.relay_node == NO_RELAY_NODE || nextHopByte != p.relay_node) { + LOG_DEBUG("Ignore traceroute next-hop 0x%02x, packet was relayed by 0x%02x", nextHopByte, p.relay_node); + return; + } + // For the rest of the nodes in the route, set their next-hop // Note: if we are the last in the route, this loop will not run for (int8_t i = nextHopIndex; i < r->route_count; i++) { diff --git a/src/modules/TrafficManagementModule.cpp b/src/modules/TrafficManagementModule.cpp index 74e8de6c2..e75273b78 100644 --- a/src/modules/TrafficManagementModule.cpp +++ b/src/modules/TrafficManagementModule.cpp @@ -32,39 +32,34 @@ namespace constexpr uint32_t kMaintenanceIntervalMs = 60 * 1000UL; // Cache cleanup interval -// NodeInfo direct response: enforced maximum hops by device role -// Both use maxHops logic (respond when hopsAway <= threshold) -// Config value is clamped to these role-based limits -// Note: nodeinfo_direct_response must also be enabled for this to take effect +// NodeInfo direct response: role-enforced hop ceilings (respond when hopsAway <= threshold); +// config can only tighten them. nodeinfo_direct_response must also be enabled. constexpr uint32_t kRouterDefaultMaxHops = 3; // Routers: max 3 hops (can set lower via config) constexpr uint32_t kClientDefaultMaxHops = 0; // Clients: direct only (cannot increase) -/** - * Convert seconds to milliseconds with overflow protection. - */ +// Staleness window: never spoof a reply for a node not actually heard within it, or a cached +// entry would be served indefinitely for a long-gone node while the genuine request is +// suppressed. The cache path enforces the same 6 h in ticks (kNodeInfoMaxServeAgeTicks, header). +constexpr uint32_t kNodeInfoMaxServeAgeSecs = 6UL * 60UL * 60UL; // 6 h (NodeDB fallback path) + +/// Convert seconds to milliseconds with overflow protection. uint32_t secsToMs(uint32_t secs) { - uint64_t ms = static_cast(secs) * 1000ULL; - if (ms > UINT32_MAX) + uint64_t milliseconds = static_cast(secs) * 1000ULL; + if (milliseconds > UINT32_MAX) return UINT32_MAX; - return static_cast(ms); + return static_cast(milliseconds); } -// Advertised role of the originating node (from NodeDB), or CLIENT (no exception) if unknown. -// Position filtering grants two role exceptions: trackers may refresh duplicates hourly, and -// lost-and-found is throttled only to the shortest dedup window. Both are still subject to -// the channel-precision ceiling in alterReceived(). +/// Advertised role of the originating node, resolved hot store -> warm tier -> CLIENT, so the +/// position dedup role exceptions keep firing for nodes aged out of the hot store. meshtastic_Config_DeviceConfig_Role originRole(NodeNum from) { - // Resolve via NodeDB: hot store (with user) โ†’ warm-tier cached role โ†’ CLIENT. The - // warm fallback keeps role exceptions firing for trackers/etc. aged out of the hot store. return nodeDB ? nodeDB->getNodeRole(from) : meshtastic_Config_DeviceConfig_Role_CLIENT; } -/** - * Clamp precision to a valid dedup range. - * Invalid values use the module default precision. - */ +/// Clamp precision to a valid dedup range. +/// Invalid values use the module default precision. uint8_t sanitizePositionPrecision(uint8_t precision) { if (precision > 0 && precision <= 32) @@ -78,20 +73,16 @@ uint8_t sanitizePositionPrecision(uint8_t precision) return 32; } -/** - * Saturating increment for uint8_t counters. - * Prevents overflow by capping at UINT8_MAX (255). - */ +/// Saturating increment for uint8_t counters. +/// Prevents overflow by capping at UINT8_MAX (255). inline void saturatingIncrement(uint8_t &counter) { if (counter < UINT8_MAX) counter++; } -/** - * Return a short human-readable name for common port numbers. - * Falls back to "port:" for unknown ports. - */ +/// Return a short human-readable name for common port numbers. +/// Falls back to "port:" for unknown ports. const char *portName(int portnum) { switch (portnum) { @@ -132,6 +123,7 @@ TrafficManagementModule *trafficManagementModule; // Constructor // ============================================================================= +/// Allocate the unified cache (and, where available, the NodeInfo cache) and start the sweep. TrafficManagementModule::TrafficManagementModule() : MeshModule("TrafficManagement"), concurrency::OSThread("TrafficManagement") { // Module configuration @@ -167,11 +159,13 @@ TrafficManagementModule::TrafficManagementModule() : MeshModule("TrafficManageme memaudit::set("tmm", cache ? allocSize * sizeof(UnifiedCacheEntry) : 0); #endif // TRAFFIC_MANAGEMENT_CACHE_SIZE > 0 -#if defined(ARCH_ESP32) && defined(BOARD_HAS_PSRAM) - TM_LOG_INFO("Allocating NodeInfo cache: %u entries, %u bytes (PSRAM flat array)", - static_cast(nodeInfoTargetEntries()), +#if TMM_HAS_NODEINFO_CACHE + TM_LOG_INFO("Allocating NodeInfo cache: %u entries, %u bytes (flat array)", static_cast(nodeInfoTargetEntries()), static_cast(nodeInfoTargetEntries() * sizeof(NodeInfoPayloadEntry))); +#if defined(ARCH_ESP32) && defined(BOARD_HAS_PSRAM) + // Production home of this cache: ESP32 PSRAM. No heap fallback here - at 2000 entries the + // array is too large for MCU internal RAM, so a PSRAM failure disables the cache instead. nodeInfoPayload = static_cast(ps_calloc(nodeInfoTargetEntries(), sizeof(NodeInfoPayloadEntry))); if (nodeInfoPayload) { nodeInfoPayloadFromPsram = true; @@ -179,22 +173,25 @@ TrafficManagementModule::TrafficManagementModule() : MeshModule("TrafficManageme } else { TM_LOG_WARN("NodeInfo PSRAM payload allocation failed; direct responses will fall back to NodeDB"); } +#else + // Native unit-test build (see TMM_HAS_NODEINFO_CACHE): plain heap, so the cache paths + // run in CI. nodeInfoPayloadFromPsram stays false and the destructor uses delete[]. + nodeInfoPayload = new NodeInfoPayloadEntry[nodeInfoTargetEntries()](); +#endif memaudit::set("tmm_ni", nodeInfoPayload ? nodeInfoTargetEntries() * sizeof(NodeInfoPayloadEntry) : 0); #else - TM_LOG_DEBUG("NodeInfo PSRAM cache not available on this target"); + TM_LOG_DEBUG("NodeInfo cache not available on this target"); #endif setIntervalFromNow(kMaintenanceIntervalMs); } -// Cache may have been allocated via ps_calloc (PSRAM, C allocator) or new[] (heap). -// Must use the matching deallocator: free() for ps_calloc, delete[] for new[]. +/// Both caches may come from ps_calloc (PSRAM, C allocator) or new[] (heap); +/// each must be released with the matching deallocator. TrafficManagementModule::~TrafficManagementModule() { #if TRAFFIC_MANAGEMENT_CACHE_SIZE > 0 if (cache) { - // Cache may be from ps_calloc (PSRAM, C allocator) or new[] (heap). - // Use the matching deallocator for the allocation source. if (cacheFromPsram) free(cache); else @@ -224,19 +221,6 @@ meshtastic_TrafficManagementStats TrafficManagementModule::getStats() const return stats; } -void TrafficManagementModule::resetStats() -{ - concurrency::LockGuard guard(&cacheLock); - stats = meshtastic_TrafficManagementStats_init_zero; -} - -void TrafficManagementModule::recordRouterHopPreserved() -{ - // router_preserve_hops: not suitable right now - removed from config until - // the right heuristic for when to preserve vs. exhaust is clearer. - (void)stats.router_hops_preserved; -} - void TrafficManagementModule::incrementStat(uint32_t *field) { concurrency::LockGuard guard(&cacheLock); @@ -247,9 +231,7 @@ void TrafficManagementModule::incrementStat(uint32_t *field) // Flat Unified Cache Operations // ============================================================================= -/** - * Find an existing entry for the given node (linear scan). - */ +/// Find an existing entry for the given node (linear scan). TrafficManagementModule::UnifiedCacheEntry *TrafficManagementModule::findEntry(NodeNum node) { #if TRAFFIC_MANAGEMENT_CACHE_SIZE == 0 @@ -279,25 +261,57 @@ int TrafficManagementModule::peekCachedRole(NodeNum node) #endif } -/** - * Find or create an entry for the given node. - * - * One linear pass tracks the match, the first empty slot, and the eviction - * victim. When the cache is full, the victim is the stalest entry (largest - * of its three relative timestamps is smallest), preferring entries without - * a next_hop hint - those hints are the long-tail routing state the cache - * exists to keep, and the maintenance sweep never ages them out. - * - * @param node NodeNum to find or create - * @param isNew Set to true if a new entry was created - * @return Pointer to entry, or nullptr if the cache is unavailable - */ -// Sender-role resolution for the position hot path. The tier-3 cache is authoritative -// here and is kept fresh by updateCachedRoleFromNodeInfo() - i.e. updated at the same -// time NodeDB learns a role, not re-derived on every packet. We only fall back to a -// NodeDB scan (tiers 1+2) the first time we start tracking a node, to seed the cache so -// a resident special-role node is correct from its very first position. Thereafter the -// read is O(1) and survives the node aging out of both NodeDB stores. +// The two caches are compile-time independent (TMM_HAS_NODEINFO_CACHE keys on PSRAM or +// native tests, the +// unified cache on a per-variant size that may be overridden to 0), so each is purged under +// its own guard - a build with only one of them must still forget deleted nodes. +void TrafficManagementModule::purgeNode(NodeNum node) +{ +#if TRAFFIC_MANAGEMENT_CACHE_SIZE > 0 || TMM_HAS_NODEINFO_CACHE + if (node == 0) + return; + concurrency::LockGuard guard(&cacheLock); + bool purged = false; +#if TRAFFIC_MANAGEMENT_CACHE_SIZE > 0 + UnifiedCacheEntry *entry = findEntry(node); + if (entry) { + memset(entry, 0, sizeof(UnifiedCacheEntry)); + purged = true; + } +#endif + // No NodeInfo-cache guard needed: without the cache this is a no-op stub returning null. + NodeInfoPayloadEntry *info = findNodeInfoEntryMutable(node); + if (info) { + memset(info, 0, sizeof(NodeInfoPayloadEntry)); + purged = true; + } + // Log only real purges: removeNodeByNum() calls this for every deletion, including + // nodes these caches never tracked. + if (purged) + TM_LOG_INFO("Purged node 0x%08x from traffic caches", node); +#else + (void)node; +#endif +} + +void TrafficManagementModule::purgeAll() +{ +#if TRAFFIC_MANAGEMENT_CACHE_SIZE > 0 || TMM_HAS_NODEINFO_CACHE + concurrency::LockGuard guard(&cacheLock); +#if TRAFFIC_MANAGEMENT_CACHE_SIZE > 0 + if (cache) + memset(cache, 0, static_cast(cacheSize()) * sizeof(UnifiedCacheEntry)); +#endif + // nodeInfoPayload stays nullptr on builds without the NodeInfo cache; no guard needed. + if (nodeInfoPayload) + memset(nodeInfoPayload, 0, static_cast(nodeInfoTargetEntries()) * sizeof(NodeInfoPayloadEntry)); + TM_LOG_INFO("Purged all traffic caches"); +#endif +} + +/// Sender-role resolution for the position hot path. NodeDB is scanned only when a node is first +/// tracked (seeding the tier-3 cache so a resident special-role node is correct from its first +/// position); thereafter the cached role is authoritative, kept fresh by updateCachedRoleFromNodeInfo(). meshtastic_Config_DeviceConfig_Role TrafficManagementModule::resolveSenderRole(NodeNum from, UnifiedCacheEntry *entry, bool isNew) { if (!entry) @@ -313,12 +327,9 @@ meshtastic_Config_DeviceConfig_Role TrafficManagementModule::resolveSenderRole(N return static_cast(entry->getCachedRole()); } -// Refresh the tier-3 role cache from an observed NodeInfo - the same event that updates -// NodeDB's role - so role changes (including demotion back to CLIENT) are picked up -// without scanning NodeDB on the position hot path. Role is read straight from the -// packet's User payload (authoritative regardless of module ordering). Only updates nodes -// we already track (findEntry, no create) so NodeInfo from non-position nodes can't pollute -// the cache; the role rides along with the node's existing position/rate/unknown state. +/// Refresh the tier-3 role cache from an observed NodeInfo (the same event that updates NodeDB's +/// role), reading the role straight from the packet's User payload. Only updates nodes already +/// tracked, so NodeInfo from non-position nodes can't pollute the cache. void TrafficManagementModule::updateCachedRoleFromNodeInfo(const meshtastic_MeshPacket &mp) { #if TRAFFIC_MANAGEMENT_CACHE_SIZE > 0 @@ -337,6 +348,9 @@ void TrafficManagementModule::updateCachedRoleFromNodeInfo(const meshtastic_Mesh #endif } +/// Find or create the unified-cache entry for `node`. One linear pass tracks the match, the +/// first empty slot, and the eviction victim: when full, the stalest entry loses, preferring +/// entries without a next-hop hint or special role (the long-tail state this cache retains). TrafficManagementModule::UnifiedCacheEntry *TrafficManagementModule::findOrCreateEntry(NodeNum node, bool *isNew) { #if TRAFFIC_MANAGEMENT_CACHE_SIZE == 0 @@ -356,37 +370,35 @@ TrafficManagementModule::UnifiedCacheEntry *TrafficManagementModule::findOrCreat uint8_t victimRecency = UINT8_MAX; for (uint16_t i = 0; i < cacheSize(); i++) { - UnifiedCacheEntry &e = cache[i]; - if (e.node == node) - return &e; - if (e.node == 0) { + UnifiedCacheEntry &entry = cache[i]; + if (entry.node == node) + return &entry; + if (entry.node == 0) { if (!empty) - empty = &e; + empty = &entry; continue; } if (empty) continue; // an empty slot beats any victim; stop scoring - // "Preferred" entries are evicted last: a confirmed next-hop hint (routing overflow - // store) or a cached special (non-CLIENT) role (tracker / lost-and-found / router). - // Both are the long-tail state this cache exists to retain. - const bool preferred = e.next_hop != 0 || e.getCachedRole() != meshtastic_Config_DeviceConfig_Role_CLIENT; - // Age in pos-ticks (8-bit modular, wraps correctly). Entries with no - // pos state (pos_time==0) score as maximally old (age=currentPosTick()). + // "Preferred" entries are evicted last: a confirmed next-hop hint or a cached + // special (non-CLIENT) role - the long-tail state this cache exists to retain. + const bool preferred = entry.next_hop != 0 || entry.getCachedRole() != meshtastic_Config_DeviceConfig_Role_CLIENT; + // Age in pos-ticks (8-bit modular). Entries with no pos state score as maximally old. const uint8_t nowPosTick = currentPosTick(); - const uint8_t posAge = static_cast(nowPosTick - e.pos_time); + const uint8_t posAge = static_cast(nowPosTick - entry.pos_time); // Blend in rate/unknown ages scaled to pos-tick units (coarser = conservative). const uint8_t rateAgePosScale = - static_cast(static_cast((currentRateTick() - e.getRateTime()) & 0x0F) * 5 / 3); + static_cast(static_cast((currentRateTick() - entry.getRateTime()) & 0x0F) * 5 / 3); const uint8_t unknownAgePosScale = - static_cast(static_cast((currentUnknownTick() - e.getUnknownTime()) & 0x0F) / 6); + static_cast(static_cast((currentUnknownTick() - entry.getUnknownTime()) & 0x0F) / 6); uint8_t recencyAge = posAge; - if (e.getRateCount() != 0 && rateAgePosScale > recencyAge) + if (entry.getRateCount() != 0 && rateAgePosScale > recencyAge) recencyAge = rateAgePosScale; - if (e.getUnknownCount() != 0 && unknownAgePosScale > recencyAge) + if (entry.getUnknownCount() != 0 && unknownAgePosScale > recencyAge) recencyAge = unknownAgePosScale; const uint8_t recency = static_cast(UINT8_MAX - recencyAge); if (!victim || (preferred == leastPreferredVictim ? recency < victimRecency : !preferred)) { - victim = &e; + victim = &entry; leastPreferredVictim = preferred; victimRecency = recency; } @@ -405,9 +417,16 @@ TrafficManagementModule::UnifiedCacheEntry *TrafficManagementModule::findOrCreat #endif } +// ============================================================================= +// NodeInfo Payload Cache +// ============================================================================= +// One region for every NodeInfo-cache-only function; the #else block at the end +// provides the no-op stubs, so call sites need no guards of their own. Inner +// guards below are only for orthogonal features (PKI, warm tier). +#if TMM_HAS_NODEINFO_CACHE + const TrafficManagementModule::NodeInfoPayloadEntry *TrafficManagementModule::findNodeInfoEntry(NodeNum node) const { -#if defined(ARCH_ESP32) && defined(BOARD_HAS_PSRAM) if (!nodeInfoPayload || node == 0) return nullptr; @@ -416,68 +435,65 @@ const TrafficManagementModule::NodeInfoPayloadEntry *TrafficManagementModule::fi return &nodeInfoPayload[i]; } return nullptr; -#else - (void)node; - return nullptr; -#endif } -/** - * Find or create a NodeInfo payload entry (linear scan of the flat PSRAM - * array). One pass tracks the match, the first empty slot, and the LRU - * victim by lastObservedMs (wrap-safe age). NodeInfo traffic is low-rate, - * so the O(n) scan is negligible. - */ -TrafficManagementModule::NodeInfoPayloadEntry *TrafficManagementModule::findOrCreateNodeInfoEntry(NodeNum node, - bool *usedEmptySlot) +/// Find or create a NodeInfo payload entry. Victim selection is trust-tiered so the cache +/// doubles as a pubkey pool: NodeDB membership outranks key trust, then keyless < TOFU key < +/// signer-proven key; within a tier the oldest observation loses (never-observed = oldest). +TrafficManagementModule::NodeInfoPayloadEntry * +TrafficManagementModule::findOrCreateNodeInfoEntry(NodeNum node, bool *usedEmptySlot, bool spareMembers) { if (usedEmptySlot) *usedEmptySlot = false; -#if defined(ARCH_ESP32) && defined(BOARD_HAS_PSRAM) if (!nodeInfoPayload || node == 0) return nullptr; NodeInfoPayloadEntry *empty = nullptr; - NodeInfoPayloadEntry *lru = nullptr; - uint32_t lruAge = 0; - const uint32_t now = clockMs(); + NodeInfoPayloadEntry *victim = nullptr; + uint8_t victimTier = 0xFF; + uint8_t victimAge = 0; + const uint8_t nowObs = currentObsTick(); for (uint16_t i = 0; i < nodeInfoTargetEntries(); i++) { - NodeInfoPayloadEntry &e = nodeInfoPayload[i]; - if (e.node == node) - return &e; - if (e.node == 0) { + NodeInfoPayloadEntry &entry = nodeInfoPayload[i]; + if (entry.node == node) + return &entry; + if (entry.node == 0) { if (!empty) - empty = &e; + empty = &entry; continue; } if (empty) - continue; // an empty slot beats any victim; stop scoring - const uint32_t age = now - e.lastObservedMs; // unsigned subtraction is wrap-safe - if (!lru || age > lruAge) { - lru = &e; - lruAge = age; + continue; // an empty slot beats any victim; stop scoring + // Eviction tier (lower loses first): 0 keyless, 1 TOFU key, 2 signer-proven key; + // +3 for NodeDB members - never shed a NodeDB-tier identity over a stranger. + const uint8_t tier = static_cast(((entry.user.public_key.size != 32) ? 0 : (entry.keySignerProven ? 2 : 1)) + + (entry.isMember ? 3 : 0)); + // Modular observation age; saturation keeps real ages far below the 0xFF a + // never-observed entry scores, so that entry is always the oldest in its tier. + const uint8_t age = entry.hasObserved ? static_cast(nowObs - entry.obsTick) : 0xFF; + if (!victim || tier < victimTier || (tier == victimTier && age > victimAge)) { + victim = &entry; + victimTier = tier; + victimAge = age; } } - NodeInfoPayloadEntry *slot = empty ? empty : lru; + NodeInfoPayloadEntry *slot = empty ? empty : victim; if (!slot) return nullptr; + if (spareMembers && slot == victim && victim->isMember) + return nullptr; // caller would rather skip than churn one member out for another memset(slot, 0, sizeof(NodeInfoPayloadEntry)); slot->node = node; if (usedEmptySlot) *usedEmptySlot = (slot == empty); return slot; -#else - (void)node; - return nullptr; -#endif } uint16_t TrafficManagementModule::countNodeInfoEntriesLocked() const { -#if defined(ARCH_ESP32) && defined(BOARD_HAS_PSRAM) if (!nodeInfoPayload) return 0; @@ -487,14 +503,265 @@ uint16_t TrafficManagementModule::countNodeInfoEntriesLocked() const count++; } return count; -#else - return 0; -#endif } +void TrafficManagementModule::reconcileNodeInfoFromNodeDBLocked() +{ + if (!nodeInfoPayload || !nodeDB) + return; + + const NodeNum self = nodeDB->getNodeNum(); + uint16_t seeded = 0; + + // Upsert one NodeDB-tier node (`hot` non-null = full identity, warm record = key-only). + // A miss scans the whole array, so this pass is O(members x entries) - which is why it + // runs hourly plus one boot seed; the write-through hooks carry the interim. + auto reconcileOne = [&](NodeNum node, const uint8_t *key32, bool signerKnown, const meshtastic_NodeInfoLite *hot) { + if (node == 0 || node == self) + return; + if (!hot && !key32) + return; // a warm record without a key has nothing worth seeding + + NodeInfoPayloadEntry *entry = findNodeInfoEntryMutable(node); + if (!entry) { + bool usedEmptySlot = false; + entry = findOrCreateNodeInfoEntry(node, &usedEmptySlot, /*spareMembers=*/true); + if (!entry) + return; // cache is member-saturated; skip rather than churn a member out + seeded++; + } + + // NodeDB is the authority on identity content: adopt its User payload and key. A key + // change against a stale TMM TOFU pin resets provenance (the signer verdict transfers + // only key-matched, below). hasObserved/obsTick stay untouched: seeding is not observation. + const bool keyChanged = + entry->user.public_key.size == 32 && key32 && memcmp(entry->user.public_key.bytes, key32, 32) != 0; + if (hot && nodeInfoLiteHasUser(hot)) { + meshtastic_User merged = TypeConversions::ConvertToUser(hot); + // Same rule as onNodeIdentityCommitted: a keyless hot identity must not cost this + // cache a TOFU key it already learned (the kept key stays unproven). + if (merged.public_key.size != 32 && entry->user.public_key.size == 32) + merged.public_key = entry->user.public_key; + entry->user = merged; + entry->hasFullUser = true; + snprintf(entry->user.id, sizeof(entry->user.id), "!%08x", node); + } else if (key32) { + memcpy(entry->user.public_key.bytes, key32, 32); + entry->user.public_key.size = 32; + } + if (keyChanged) + entry->keySignerProven = false; + if (signerKnown && key32 && entry->user.public_key.size == 32 && memcmp(entry->user.public_key.bytes, key32, 32) == 0) + entry->keySignerProven = true; + entry->isMember = true; + }; + + for (size_t i = 0; i < nodeDB->getNumMeshNodes(); i++) { + const meshtastic_NodeInfoLite *info = nodeDB->getMeshNodeByIndex(i); + if (!info || info->num == 0) + continue; + const uint8_t *hotKey = (info->public_key.size == 32) ? info->public_key.bytes : nullptr; + reconcileOne(info->num, hotKey, nodeInfoLiteHasXeddsaSigned(info), info); + } +#if WARM_NODE_COUNT > 0 + // Warm tier: key-only records (the warm tier keeps no names), so the cache holds a key + // for every NodeDB identity - the superset the pubkey pool and the key pin rely on. + for (size_t i = 0; i < nodeDB->warmStore.capacity(); i++) { + const WarmNodeEntry *warm = nodeDB->warmStore.entryAt(i); + if (!warm) + continue; + const bool hasKey = !memfll(warm->public_key, 0, sizeof(warm->public_key)); + reconcileOne(warm->num, hasKey ? warm->public_key : nullptr, warmSignerOf(*warm), nullptr); + } +#endif + + // Membership refresh (this hourly pass owns it): clear every isMember bit, then re-mark from + // both NodeDB tiers. Runs AFTER seeding so the upsert still sees last pass's bits (spareMembers). + // Cost/lag rationale in docs/node_info_stores.md "Consistency with NodeDB (anti-entropy)". + for (uint16_t i = 0; i < nodeInfoTargetEntries(); i++) + nodeInfoPayload[i].isMember = false; + for (size_t i = 0; i < nodeDB->getNumMeshNodes(); i++) { + const meshtastic_NodeInfoLite *info = nodeDB->getMeshNodeByIndex(i); + if (!info || info->num == 0) + continue; + NodeInfoPayloadEntry *entry = findNodeInfoEntryMutable(info->num); + if (entry) + entry->isMember = true; + } +#if WARM_NODE_COUNT > 0 + for (size_t i = 0; i < nodeDB->warmStore.capacity(); i++) { + const WarmNodeEntry *warm = nodeDB->warmStore.entryAt(i); + if (!warm) + continue; + NodeInfoPayloadEntry *entry = findNodeInfoEntryMutable(warm->num); + if (entry) + entry->isMember = true; + } +#endif + + if (seeded) + TM_LOG_INFO("NodeInfo cache reconciled: %u seeded from NodeDB, %u/%u total", static_cast(seeded), + static_cast(countNodeInfoEntriesLocked()), static_cast(nodeInfoTargetEntries())); +} + +void TrafficManagementModule::maintainNodeInfoCacheLocked() +{ + if (!nodeInfoPayload) + return; + + // Saturate expired tick stamps: clearing the presence bit once a stamp's age exceeds + // its window is the wrap-safety guarantee (stamps never approach their uint8 aliasing + // horizon). Entries are never freed on a timer - slots die by tiered LRU or purge. + uint16_t nodeInfoSaturated = 0; + const uint8_t nowObs = currentObsTick(); + for (uint16_t i = 0; i < nodeInfoTargetEntries(); i++) { + NodeInfoPayloadEntry &entry = nodeInfoPayload[i]; + if (entry.node == 0) + continue; + if (entry.hasObserved && static_cast(nowObs - entry.obsTick) > kNodeInfoMaxServeAgeTicks) { + entry.hasObserved = false; + nodeInfoSaturated++; + } + // Membership is NOT refreshed here: a per-entry NodeDB lookup would be + // O(entries x members) every 60 s under cacheLock. The hourly reconcile pass + // owns it (see reconcileNodeInfoFromNodeDBLocked). + } + TM_LOG_DEBUG("NodeInfo cache: %u/%u (%u went stale)", static_cast(countNodeInfoEntriesLocked()), + static_cast(nodeInfoTargetEntries()), static_cast(nodeInfoSaturated)); + + // Anti-entropy: seed identities NodeDB knows but this cache lacks - a full pass at + // boot (once nodeDB is ready), then hourly. The write-through hooks provide + // immediacy between passes. + if (!nodeInfoSeeded || ++sweepsSinceNodeInfoReconcile >= kNodeInfoReconcileSweeps) { + if (nodeDB) { + reconcileNodeInfoFromNodeDBLocked(); + nodeInfoSeeded = true; + sweepsSinceNodeInfoReconcile = 0; + } + } +} + +void TrafficManagementModule::onNodeIdentityCommitted(NodeNum node, const meshtastic_User &user, bool signerKnown) +{ + // Same gate as handleReceived()/runOnce(): content and maintenance stay keyed to the + // same condition. Defensive - the object is only constructed while the flag is set + // (Modules.cpp) and no live path clears it (AdminModule only writes it true; config + // changes reboot), so a disabled module never reaches these hooks with a live cache. + if (!moduleConfig.has_traffic_management) + return; + if (node == 0 || (nodeDB && node == nodeDB->getNodeNum())) + return; + concurrency::LockGuard guard(&cacheLock); + if (!nodeInfoPayload) + return; + bool usedEmptySlot = false; + NodeInfoPayloadEntry *entry = findOrCreateNodeInfoEntry(node, &usedEmptySlot, /*spareMembers=*/true); + if (!entry) + return; // member-saturated cache: the reconcile sweep owns the tradeoff + + // Merge: NodeDB's commit is authoritative for everything it carries, but a keyless + // commit (possible while the node is unpinned in NodeDB) must not cost this cache a + // TOFU key it already learned. + meshtastic_User merged = user; + if (merged.public_key.size != 32 && !usedEmptySlot && entry->user.public_key.size == 32) + merged.public_key = entry->user.public_key; + + // Provenance survives only alongside an unchanged key; a replaced key starts from scratch + // and may be re-proven by signerKnown below - which vouches for the COMMITTED key only. + const bool sameKey = !usedEmptySlot && entry->user.public_key.size == 32 && merged.public_key.size == 32 && + memcmp(entry->user.public_key.bytes, merged.public_key.bytes, 32) == 0; + const bool provenBefore = !usedEmptySlot && entry->keySignerProven && sameKey; + + entry->user = merged; + snprintf(entry->user.id, sizeof(entry->user.id), "!%08x", node); + entry->hasFullUser = true; + entry->keySignerProven = provenBefore || (signerKnown && user.public_key.size == 32); + entry->isMember = true; // committed via updateUser => it sits in the hot store right now + // obsTick/hasObserved deliberately untouched: only a heard frame makes a node servable. +} + +void TrafficManagementModule::onNodeKeyCommitted(NodeNum node, const uint8_t key32[32], bool proven) +{ + // Same module-disabled gate as onNodeIdentityCommitted (see there for rationale). + if (!moduleConfig.has_traffic_management) + return; + if (node == 0 || !key32 || (nodeDB && node == nodeDB->getNodeNum())) + return; + concurrency::LockGuard guard(&cacheLock); + if (!nodeInfoPayload) + return; + bool usedEmptySlot = false; + NodeInfoPayloadEntry *entry = findOrCreateNodeInfoEntry(node, &usedEmptySlot, /*spareMembers=*/true); + if (!entry) + return; + + const bool keyChanged = entry->user.public_key.size == 32 && memcmp(entry->user.public_key.bytes, key32, 32) != 0; + memcpy(entry->user.public_key.bytes, key32, 32); + entry->user.public_key.size = 32; + entry->isMember = true; // the caller just committed it to the hot store + // A rotated key never inherits the old key's verdict; `proven` (manual verification of + // exactly this key) is the strongest provenance this cache can carry. + if (keyChanged) + entry->keySignerProven = false; + if (proven) + entry->keySignerProven = true; + // hasObserved/obsTick untouched: a key commit is knowledge, not an observation. +} + +bool TrafficManagementModule::copyPublicKey(NodeNum node, uint8_t out[32], bool *signerProven) const +{ + // Same enable gate as the write-through hooks and maintenance: a disabled module stops + // updating and sweeping the cache, so its frozen contents must not keep feeding PKI key + // resolution either. Enforces the "superset only while enabled" corollary (node_info_stores.md). + if (!moduleConfig.has_traffic_management) + return false; + if (!nodeInfoPayload || node == 0 || !out) + return false; + + concurrency::LockGuard guard(&cacheLock); + const NodeInfoPayloadEntry *entry = findNodeInfoEntry(node); + if (!entry || entry->user.public_key.size != 32) + return false; + + memcpy(out, entry->user.public_key.bytes, 32); + if (signerProven) + *signerProven = entry->keySignerProven; + return true; +} + +bool TrafficManagementModule::copyUser(NodeNum node, meshtastic_User &out, bool *signerProven) const +{ + // Enable gate, as in copyPublicKey(): a disabled module must not feed name rehydration + // from frozen cache contents once its maintenance/write-through have stopped. + if (!moduleConfig.has_traffic_management) + return false; + if (!nodeInfoPayload || node == 0) + return false; + + concurrency::LockGuard guard(&cacheLock); + const NodeInfoPayloadEntry *entry = findNodeInfoEntry(node); + // Key-only records (seeded from the warm tier, which keeps no names) are not a User: + // handing one to name-rehydration would stamp HAS_USER onto a nameless node. + if (!entry || !entry->hasFullUser) + return false; + + out = entry->user; + if (signerProven) + *signerProven = entry->keySignerProven; + return true; +} + +#if !(MESHTASTIC_EXCLUDE_PKI) +/// True iff both are full 32-byte public keys with identical bytes. Single point of truth for +/// the key-hygiene checks; raw ptr+size because User and NodeInfoLite key fields differ in type. +static bool pubKeysEqual(const uint8_t *keyA, size_t sizeA, const uint8_t *keyB, size_t sizeB) +{ + return sizeA == 32 && sizeB == 32 && memcmp(keyA, keyB, 32) == 0; +} +#endif + void TrafficManagementModule::cacheNodeInfoPacket(const meshtastic_MeshPacket &mp) { -#if defined(ARCH_ESP32) && defined(BOARD_HAS_PSRAM) if (!nodeInfoPayload || mp.decoded.payload.size == 0) return; @@ -502,50 +769,165 @@ void TrafficManagementModule::cacheNodeInfoPacket(const meshtastic_MeshPacket &m if (!pb_decode_from_bytes(mp.decoded.payload.bytes, mp.decoded.payload.size, &meshtastic_User_msg, &user)) return; + const NodeNum from = getFrom(&mp); + // Normalize user.id to the packet sender's node number. - snprintf(user.id, sizeof(user.id), "!%08x", getFrom(&mp)); + snprintf(user.id, sizeof(user.id), "!%08x", from); + + // Whether NodeDB already knows this node as a verified signer for this key. Lets a + // re-found node inherit proven provenance even when THIS frame happens to be unsigned. + bool dbSaysSigner = false; + +#if !(MESHTASTIC_EXCLUDE_PKI) + // This cache is served back as spoofed replies, so mirror NodeDB::updateUser()'s key + // hygiene. First: a frame advertising our own key is impersonating us - never cache it. + if (pubKeysEqual(user.public_key.bytes, user.public_key.size, owner.public_key.bytes, owner.public_key.size)) { + TM_LOG_WARN("NodeInfo cache: incoming key matches owner, dropping 0x%08x", from); + return; + } + // Key pin against the authoritative NodeDB key (hot store, then warm tier - the same + // coverage as updateUser's pin): a hot-only check would let an attacker seed a bogus key + // for a warm-evicted node, and the TOFU pin below would then lock the genuine node out. + meshtastic_NodeInfoLite_public_key_t dbKey = {0, {0}}; + if (nodeDB && nodeDB->copyPublicKeyAuthoritative(from, dbKey) && + !pubKeysEqual(user.public_key.bytes, user.public_key.size, dbKey.bytes, dbKey.size)) { + TM_LOG_WARN("NodeInfo cache: public key mismatch vs NodeDB, dropping 0x%08x", from); + return; + } + // Re-found in NodeDB as a known signer: inherit that verdict even off an unsigned frame. + // isVerifiedSignerForKey covers both tiers and requires the key to match, so a warm-only + // signer is included and a rotated key never inherits a stale verdict. + dbSaysSigner = nodeDB && user.public_key.size == 32 && nodeDB->isVerifiedSignerForKey(from, user.public_key.bytes); +#endif bool usedEmptySlot = false; uint16_t cachedCount = 0; { concurrency::LockGuard guard(&cacheLock); - NodeInfoPayloadEntry *entry = findOrCreateNodeInfoEntry(getFrom(&mp), &usedEmptySlot); + NodeInfoPayloadEntry *entry = findOrCreateNodeInfoEntry(from, &usedEmptySlot); if (!entry) return; - // Cache both payload and response metadata so direct replies can use - // richer context than "just the user protobuf" when PSRAM is present. - // This path is intentionally independent from NodeInfoModule/NodeDB. +#if !(MESHTASTIC_EXCLUDE_PKI) + // Trust-on-first-use pinning within our own cache: if NodeDB has since evicted the + // node but we already cached a key for it, still refuse a mismatching key. (A matched + // existing slot is returned un-zeroed, so entry->user holds the previously cached key.) + if (!usedEmptySlot && entry->node == from && entry->user.public_key.size == 32 && + !pubKeysEqual(user.public_key.bytes, user.public_key.size, entry->user.public_key.bytes, + entry->user.public_key.size)) { + TM_LOG_WARN("NodeInfo cache: public key mismatch vs cache, dropping 0x%08x", from); + return; + } +#endif + + // Cache payload plus response metadata so direct replies carry richer context than + // just the User protobuf. Intentionally independent from NodeInfoModule/NodeDB. entry->user = user; - entry->lastObservedMs = clockMs(); - entry->lastObservedRxTime = mp.rx_time; + entry->obsTick = currentObsTick(); // a genuine observation - the only place obsTick is stamped + entry->hasObserved = true; + entry->hasFullUser = true; // an observed NODEINFO frame always carries the full User entry->sourceChannel = mp.channel; entry->hasDecodedBitfield = mp.decoded.has_bitfield; entry->decodedBitfield = mp.decoded.bitfield; + // Upgrade to signer-proven on a Router-verified signature or a NodeDB signer verdict + // for this same key. Never downgrade (a later unsigned frame leaves the flag set), + // and the key itself cannot change here - the pin checks above already rejected that. + if ((mp.xeddsa_signed || dbSaysSigner) && user.public_key.size == 32) + entry->keySignerProven = true; + if (usedEmptySlot) cachedCount = countNodeInfoEntriesLocked(); } if (usedEmptySlot) { - TM_LOG_INFO("NodeInfo PSRAM cache entries: %u/%u", static_cast(cachedCount), + TM_LOG_INFO("NodeInfo cache entries: %u/%u", static_cast(cachedCount), static_cast(nodeInfoTargetEntries())); } -#else - (void)mp; -#endif } +void TrafficManagementModule::dropNodeInfoCacheForTest() +{ + concurrency::LockGuard guard(&cacheLock); + if (!nodeInfoPayload) + return; + if (nodeInfoPayloadFromPsram) + free(nodeInfoPayload); + else + delete[] nodeInfoPayload; + nodeInfoPayload = nullptr; + nodeInfoPayloadFromPsram = false; + memaudit::set("tmm_ni", 0); +} + +int TrafficManagementModule::peekNodeInfoFlagsForTest(NodeNum node) +{ + concurrency::LockGuard guard(&cacheLock); + const NodeInfoPayloadEntry *entry = findNodeInfoEntry(node); + if (!entry) + return -1; + return (entry->hasObserved ? 1 : 0) | (entry->isMember ? 2 : 0) | (entry->hasFullUser ? 4 : 0) | + (entry->keySignerProven ? 8 : 0); +} + +void TrafficManagementModule::markKeySignerProvenForTest(NodeNum node) +{ + concurrency::LockGuard guard(&cacheLock); + if (!nodeInfoPayload) + return; + for (uint16_t i = 0; i < nodeInfoTargetEntries(); i++) { + if (nodeInfoPayload[i].node == node) { + nodeInfoPayload[i].keySignerProven = true; + return; + } + } +} + +#else // !TMM_HAS_NODEINFO_CACHE: no-op stubs so call sites need no guards of their own + +const TrafficManagementModule::NodeInfoPayloadEntry *TrafficManagementModule::findNodeInfoEntry(NodeNum) const +{ + return nullptr; +} +TrafficManagementModule::NodeInfoPayloadEntry *TrafficManagementModule::findOrCreateNodeInfoEntry(NodeNum, bool *usedEmptySlot, + bool) +{ + if (usedEmptySlot) + *usedEmptySlot = false; + return nullptr; +} +uint16_t TrafficManagementModule::countNodeInfoEntriesLocked() const +{ + return 0; +} +void TrafficManagementModule::reconcileNodeInfoFromNodeDBLocked() {} +void TrafficManagementModule::maintainNodeInfoCacheLocked() {} +void TrafficManagementModule::onNodeIdentityCommitted(NodeNum, const meshtastic_User &, bool) {} +void TrafficManagementModule::onNodeKeyCommitted(NodeNum, const uint8_t[32], bool) {} +bool TrafficManagementModule::copyPublicKey(NodeNum, uint8_t[32], bool *) const +{ + return false; +} +bool TrafficManagementModule::copyUser(NodeNum, meshtastic_User &, bool *) const +{ + return false; +} +void TrafficManagementModule::cacheNodeInfoPacket(const meshtastic_MeshPacket &) {} +void TrafficManagementModule::dropNodeInfoCacheForTest() {} +int TrafficManagementModule::peekNodeInfoFlagsForTest(NodeNum) +{ + return -1; +} +void TrafficManagementModule::markKeySignerProvenForTest(NodeNum) {} + +#endif // TMM_HAS_NODEINFO_CACHE + // ============================================================================= // Next-Hop Overflow Cache // ============================================================================= -// -// A routing hint store. The byte is the last byte of the NodeNum to use as next -// hop to reach `dest`. It is written ONLY from NextHopRouter's ACK-confirmed -// decision (a bidirectionally-verified relay) - never inferred one-way from -// relayed traffic. The TMM cache holds confirmed next-hops that have aged out of -// the hot NodeDB (NodeInfoLite), and NextHopRouter::getNextHop() consults it as a -// fallback after the hot store. +// Routing hints (last byte of the next-hop NodeNum), written ONLY from NextHopRouter's +// ACK-confirmed decisions - never inferred one-way. Holds confirmed hops that aged out of +// the hot NodeDB; NextHopRouter::getNextHop() consults it as a fallback after the hot store. void TrafficManagementModule::setNextHop(NodeNum dest, uint8_t nextHopByte) { @@ -640,60 +1022,34 @@ void TrafficManagementModule::flushCache() // Position Hash (Compact Mode) // ============================================================================= -/** - * Compute 8-bit position fingerprint from truncated lat/lon coordinates. - * - * Unlike a hash, this is deterministic: adjacent grid cells have sequential - * fingerprints, so nearby positions never collide. The fingerprint extracts - * the lower 4 significant bits from each truncated coordinate. - * - * Example with precision=16: - * lat_truncated = 0x12340000 (top 16 bits significant) - * Significant portion = 0x1234, lower 4 bits = 0x4 - * - * fingerprint = (lat_low4 << 4) | lon_low4 = 8 bits total - * - * Collision: Two positions collide only if they differ by a multiple of 16 - * grid cells in BOTH lat and lon dimensions simultaneously - very unlikely - * for typical position update patterns. - * - * @param lat_truncated Precision-truncated latitude - * @param lon_truncated Precision-truncated longitude - * @param precision Number of significant bits (1-32) - * @return 8-bit fingerprint (4 bits lat + 4 bits lon) - */ +/// 8-bit position fingerprint: (lat_low4 << 4) | lon_low4 from the truncated coordinates' +/// lower significant bits. Deterministic, so adjacent grid cells never collide; two positions +/// collide only when 16+ cells apart in BOTH dimensions. uint8_t TrafficManagementModule::computePositionFingerprint(int32_t lat_truncated, int32_t lon_truncated, uint8_t precision) { precision = sanitizePositionPrecision(precision); - // Guard: if precision < 4, we have fewer bits to work with - // Take min(precision, 4) bits from each coordinate + // With precision < 4 there are fewer significant bits to take. uint8_t bitsToTake = (precision < 4) ? precision : 4; - // Shift to move significant bits to bottom, then mask lower bits - // For precision=16: shift by 16 to get the 16 significant bits at bottom + // Shift the significant bits to the bottom, then mask the low bits of each coordinate. uint8_t shift = 32 - precision; uint8_t latBits = (static_cast(lat_truncated) >> shift) & ((1u << bitsToTake) - 1); uint8_t lonBits = (static_cast(lon_truncated) >> shift) & ((1u << bitsToTake) - 1); - const uint8_t fp = static_cast((latBits << 4) | lonBits); - // 0 is the "no position seen" sentinel for pos_fingerprint, so a real position that happens to - // hash to 0 must not collide with it (otherwise its duplicates would never dedup). Remap 0 -> 0xFF, - // mirroring NodeDB::getLastByteOfNodeNum()'s 0 -> 0xFF idiom. Cost: the 0x00 bucket merges into - // 0xFF (one extra collision in 256 - negligible; the fingerprint already collides every 16 cells). - return fp ? fp : 0xFF; + const uint8_t fingerprint = static_cast((latBits << 4) | lonBits); + // 0 is the "no position seen" sentinel, so remap a computed 0 -> 0xFF (mirrors + // getLastByteOfNodeNum). Cost: one extra collision bucket in 256 - negligible. + return fingerprint ? fingerprint : 0xFF; } // ============================================================================= // Packet Handling // ============================================================================= -// Processing order matters: this module runs BEFORE RoutingModule in the callModules() loop. -// - STOP prevents RoutingModule from calling sniffReceived() โ†’ perhapsRebroadcast(), -// so the packet is fully consumed (not forwarded). -// - ignoreRequest suppresses the default "no one responded" NAK for want_response packets. -// - exhaustRequested is set by alterReceived() and checked by perhapsRebroadcast() to -// force hop_limit=0 on the rebroadcast copy, allowing one final relay hop. +/// Runs BEFORE RoutingModule in callModules(): STOP fully consumes the packet (no rebroadcast), +/// ignoreRequest suppresses the default NAK for want_response packets, and exhaustRequested +/// (set by alterReceived) makes perhapsRebroadcast() force hop_limit=0 on the relayed copy. ProcessMessage TrafficManagementModule::handleReceived(const meshtastic_MeshPacket &mp) { if (!moduleConfig.has_traffic_management) @@ -726,14 +1082,13 @@ ProcessMessage TrafficManagementModule::handleReceived(const meshtastic_MeshPack return ProcessMessage::CONTINUE; } - // A known signer's NodeInfo arriving unsigned is unauthenticated (the sender is forgeable), so - // it must not drive any cache or identity write below. Computed once here (getMeshNode is O(N)) - // and reused by both the cache-refresh and the direct-response identity path. + // A known signer's NodeInfo arriving unsigned is unauthenticated and must not drive any + // cache or identity write below. isKnownXeddsaSigner covers the warm tier too - a forged + // unsigned NodeInfo carrying a warm-evicted signer's real (public!) key passes the key pin. const bool isNodeInfo = mp.decoded.portnum == meshtastic_PortNum_NODEINFO_APP; - const meshtastic_NodeInfoLite *senderNode = isNodeInfo ? nodeDB->getMeshNode(getFrom(&mp)) : nullptr; - const bool unauthenticatedSigner = senderNode && nodeInfoLiteHasXeddsaSigned(senderNode) && !mp.xeddsa_signed; + const bool unauthenticatedSigner = isNodeInfo && !mp.xeddsa_signed && nodeDB && nodeDB->isKnownXeddsaSigner(getFrom(&mp)); - // Learn NodeInfo payloads into the dedicated PSRAM cache, and refresh the tier-3 + // Learn NodeInfo payloads into the dedicated NodeInfo cache, and refresh the tier-3 // role cache for any node we already track (keeps the dedup role exception current). if (isNodeInfo && !unauthenticatedSigner) { cacheNodeInfoPacket(mp); @@ -743,21 +1098,21 @@ ProcessMessage TrafficManagementModule::handleReceived(const meshtastic_MeshPack // ------------------------------------------------------------------------- // NodeInfo Direct Response // ------------------------------------------------------------------------- - // When we see a unicast NodeInfo request for a node we know about, - // respond directly from cache instead of forwarding the request. - // STOP prevents the request from being rebroadcast toward the target node, - // and our cached response is sent back to the requestor with hop_limit=0. + // Answer a unicast NodeInfo request for a known node straight from cache: STOP keeps the + // request from being rebroadcast, and the reply goes back to the requestor with hop_limit=0. if (cfg.nodeinfo_direct_response_max_hops > 0 && mp.decoded.portnum == meshtastic_PortNum_NODEINFO_APP && mp.decoded.want_response && !isBroadcast(mp.to) && !isToUs(&mp) && !isFromUs(&mp)) { if (shouldRespondToNodeInfo(&mp, true)) { // Unicast NodeInfo is never signed, so a known signer's identity claim here is // unauthenticated: don't overwrite its stored name. The cached response is unaffected. - // unauthenticatedSigner was computed above (this branch is NodeInfo-only). meshtastic_User requester = meshtastic_User_init_zero; if (!unauthenticatedSigner && pb_decode_from_bytes(mp.decoded.payload.bytes, mp.decoded.payload.size, &meshtastic_User_msg, &requester)) { - nodeDB->updateUser(getFrom(&mp), requester, mp.channel); + // Re-enters this module: updateUser's write-through hook calls back into + // onNodeIdentityCommitted, which takes cacheLock - safe here because this + // call site never holds cacheLock. + nodeDB->updateUser(getFrom(&mp), requester, mp.channel, mp.xeddsa_signed); } logAction("respond", &mp, "nodeinfo-cache"); incrementStat(&stats.nodeinfo_cache_hits); @@ -818,24 +1173,17 @@ void TrafficManagementModule::alterReceived(meshtastic_MeshPacket &mp) if (isFromUs(&mp)) return; - // exhaust_hop_telemetry / exhaust_hop_position / router_preserve_hops: - // not suitable right now - the right heuristics for when to exhaust or - // preserve hops need more field data before we expose them as config knobs. - // exhaustRequested stays false; perhapsRebroadcast() behaves normally. + // exhaust_hop_telemetry / exhaust_hop_position / router_preserve_hops: shelved until the + // right heuristics are clearer; exhaustRequested stays false and rebroadcast is normal. const bool isPosition = mp.decoded.portnum == meshtastic_PortNum_POSITION_APP; // ------------------------------------------------------------------------- // Relayed Position Precision Clamp // ------------------------------------------------------------------------- - // Clamp relayed position broadcasts to the channel's configured precision - // ceiling. Guards against forwarding more-precise coordinates than the - // channel is intended to carry (e.g. a LongFast channel set to 13-bit / - // ~1.5 km). chanPrec==0 means position sharing is disabled on the channel; - // skip - not our job to zero positions on relay. - // Ham mode (owner.is_licensed) is exempt. Lost-and-found is NOT exempt - its relayed - // positions get the same precision clamp as any node. - // Compile USERPREFS_TMM_APPLY_TO_PRIVATE_CHANNELS to extend to private channels. + // Never forward more-precise coordinates than the channel is configured to carry + // (chanPrec==0 = sharing disabled on channel: skip). Ham mode is exempt; lost-and-found + // is not. Compile USERPREFS_TMM_APPLY_TO_PRIVATE_CHANNELS to extend to private channels. if (!owner.is_licensed && isPosition && isBroadcast(mp.to)) { #ifdef USERPREFS_TMM_APPLY_TO_PRIVATE_CHANNELS const bool shouldClamp = true; @@ -870,21 +1218,23 @@ int32_t TrafficManagementModule::runOnce() return INT32_MAX; #if TRAFFIC_MANAGEMENT_CACHE_SIZE > 0 - const uint32_t nowMs = TrafficManagementModule::clockMs(); - - // Warm-start the next-hop cache from persisted NodeInfoLite hints once nodeDB - // is populated. Done here (not in the constructor) so nodeDB has finished - // loading. Takes its own lock, so call before acquiring the sweep guard below. - // Only latch the one-shot guard once the preload actually ran; if nodeDB wasn't - // ready yet, retry on the next maintenance pass instead of skipping it forever. + // Warm-start the next-hop cache once nodeDB has finished loading (hence here, not the + // constructor; it takes its own lock, so run before the sweep guard). Latch the one-shot + // guard only when the preload actually ran, so a not-ready nodeDB gets a retry. if (!nextHopPreloaded && preloadNextHopsFromNodeDB()) nextHopPreloaded = true; +#endif - // Free-running tick counters (no epoch needed). - // TTL expressed in ticks: - // pos: 4ร— position_min_interval_secs (clamped to 255 ticks @ 6 min/tick) - // rate: 2ร— rate_limit_window_secs (clamped to 15 ticks @ 5 min/tick; only relevant when rate limits are configured) - // unknown: fixed 12 ticks @ 1 min/tick (only relevant when unknown_packet_threshold > 0) +#if TRAFFIC_MANAGEMENT_CACHE_SIZE > 0 || TMM_HAS_NODEINFO_CACHE + // Mirrors purgeAll(): the two caches are compile-time independent (a variant may zero the + // unified cache while the NodeInfo cache exists, or vice versa), so each is maintained + // under its own guard below - a build with only one of them must still sweep it. + concurrency::LockGuard guard(&cacheLock); +#endif + +#if TRAFFIC_MANAGEMENT_CACHE_SIZE > 0 + // TTLs in free-running ticks: pos 4x position interval (<=255 @ 6 min/tick), rate 2x the + // configured window (<=15 @ 5 min/tick), unknown fixed 12 @ 1 min/tick. const uint32_t positionIntervalMs = secsToMs(Default::getConfiguredOrDefault( moduleConfig.traffic_management.position_min_interval_secs, default_traffic_mgmt_position_min_interval_secs)); const uint8_t posTtlTicks = @@ -906,9 +1256,6 @@ int32_t TrafficManagementModule::runOnce() uint16_t expiredEntries = 0; const uint32_t sweepStartMs = TrafficManagementModule::clockMs(); - const auto &cfg = moduleConfig.traffic_management; - concurrency::LockGuard guard(&cacheLock); - for (uint16_t i = 0; i < cacheSize(); i++) { if (cache[i].node == 0) continue; @@ -945,12 +1292,9 @@ int32_t TrafficManagementModule::runOnce() } } - // Two fields have no TTL of their own and pin the slot, so they outlive the - // dedup/rate/unknown state: - // - a confirmed next-hop hint (the routing overflow store), and - // - a cached special (non-CLIENT) role, so a tracker / lost-and-found / router - // keeps its dedup-window exception across quiet periods rather than reverting - // to CLIENT the moment its timed state expires. + // Confirmed next-hop hints and cached special roles have no TTL and pin the slot, so + // a tracker/router keeps its dedup exception across quiet periods instead of + // reverting to CLIENT the moment its timed state expires. if (cache[i].next_hop != 0 || cache[i].getCachedRole() != meshtastic_Config_DeviceConfig_Role_CLIENT) anyValid = true; @@ -967,15 +1311,10 @@ int32_t TrafficManagementModule::runOnce() static_cast(activeEntries), static_cast(cacheSize()), static_cast(TrafficManagementModule::clockMs() - sweepStartMs)); -#if defined(ARCH_ESP32) && defined(BOARD_HAS_PSRAM) - if (nodeInfoPayload) { - TM_LOG_DEBUG("NodeInfo PSRAM cache: %u/%u", static_cast(countNodeInfoEntriesLocked()), - static_cast(nodeInfoTargetEntries())); - } -#endif - #endif // TRAFFIC_MANAGEMENT_CACHE_SIZE > 0 + maintainNodeInfoCacheLocked(); // no-op stub on builds without the NodeInfo cache + return kMaintenanceIntervalMs; } @@ -1004,10 +1343,8 @@ bool TrafficManagementModule::shouldDropPosition(const meshtastic_MeshPacket *p, const int32_t lat_truncated = truncateCoordinate(pos->latitude_i, precision); const int32_t lon_truncated = truncateCoordinate(pos->longitude_i, precision); const uint8_t fingerprint = computePositionFingerprint(lat_truncated, lon_truncated, precision); - // Drop gate uses the RAW configured interval: 0 means "dedup disabled" (the - // contract documented below). The 12h default is only for resolution/TTL - // sizing (constructor / runOnce), not for deciding whether to drop - feeding - // the default here would silently turn the 0-disables-dedup contract off. + // Drop gate uses the RAW configured interval: 0 means "dedup disabled". The 12 h default + // is only for TTL sizing - feeding it here would silently defeat that contract. uint32_t minIntervalMs = secsToMs(moduleConfig.traffic_management.position_min_interval_secs); bool isNew = false; @@ -1016,11 +1353,8 @@ bool TrafficManagementModule::shouldDropPosition(const meshtastic_MeshPacket *p, if (!entry) return false; - // Role exceptions keyed on the originating node's advertised role, resolved across - // all three tiers (hot store โ†’ warm store โ†’ TMM live cache). The position path is - // the one place that needs sender-role, and it also keeps tier 3 warm so the - // exception survives the node aging out of both NodeDB stores - important in the - // common dedup-only config, where isRateLimited()'s role write never runs. + // Role exceptions keyed on the sender's advertised role, resolved hot -> warm -> tier-3 + // cache; this path also keeps tier 3 warm so the exception survives NodeDB eviction. const meshtastic_Config_DeviceConfig_Role role = resolveSenderRole(p->from, entry, isNew); if (role == meshtastic_Config_DeviceConfig_Role_LOST_AND_FOUND) { // Lost-and-found may refresh a duplicate position at most every ~15 min (cap, never @@ -1073,13 +1407,20 @@ bool TrafficManagementModule::shouldRespondToNodeInfo(const meshtastic_MeshPacke meshtastic_User cachedUser = meshtastic_User_init_zero; bool hasCachedUser = false; - // Extra metadata consumed only by the PSRAM-backed cache path. + // Extra metadata consumed only by the NodeInfo-cache path. // Defaults preserve previous behavior when cache metadata is unavailable. bool cachedHasDecodedBitfield = false; uint8_t cachedDecodedBitfield = 0; uint8_t cachedSourceChannel = 0; - uint32_t cachedLastObservedMs = 0; - uint32_t cachedLastObservedRxTime = 0; + bool cachedHasObserved = false; + uint8_t cachedObsTick = 0; + // Signer-proven provenance of the cached key, consumed by the replay gate below + // (maybe_unused: read only when TMM_NODEINFO_REPLAY_SIGNED_GATE is compiled in). + [[maybe_unused]] bool cachedKeySignerProven = false; + // True once we commit to answering from the NodeDB fallback (no NodeInfo cache) path. The + // response throttle no longer distinguishes the paths - the per-requester/per-target RAM + // tables cover both - but the replay gate below still keys off it. + bool usedFallback = false; { concurrency::LockGuard guard(&cacheLock); @@ -1090,31 +1431,77 @@ bool TrafficManagementModule::shouldRespondToNodeInfo(const meshtastic_MeshPacke cachedHasDecodedBitfield = entry->hasDecodedBitfield; cachedDecodedBitfield = entry->decodedBitfield; cachedSourceChannel = entry->sourceChannel; - cachedLastObservedMs = entry->lastObservedMs; - cachedLastObservedRxTime = entry->lastObservedRxTime; + cachedHasObserved = entry->hasObserved; + cachedObsTick = entry->obsTick; + cachedKeySignerProven = entry->keySignerProven; } } if (!hasCachedUser) { - // If the PSRAM cache exists but misses, we intentionally do not fall back - // to the node-wide table. This keeps the PSRAM direct-reply path separate - // from NodeInfoModule/NodeDB behavior when PSRAM is available. + // If the NodeInfo cache exists but misses, we intentionally do not fall back + // to the node-wide table. This keeps the cache-backed direct-reply path separate + // from NodeInfoModule/NodeDB behavior when the cache is available. if (nodeInfoPayload) { - TM_LOG_DEBUG("NodeInfo PSRAM cache miss for node=0x%08x", p->to); + TM_LOG_DEBUG("NodeInfo cache miss for node=0x%08x", p->to); return false; } - // Fallback only when PSRAM cache is unavailable on this target. + // Fallback only when the NodeInfo cache is unavailable on this target. // In this mode we use the node-wide table maintained by NodeInfoModule. const meshtastic_NodeInfoLite *node = nodeDB->getMeshNode(p->to); if (!nodeInfoLiteHasUser(node)) return false; + // Staleness gate (fallback path): don't spoof a reply for a node last heard beyond the + // serve window. last_heard == 0 means recency is unknown (clock not set) - we can't + // prove staleness, so still answer. Age computed once so test and log can't diverge. + const uint32_t nodeAgeSecs = sinceLastSeen(node); + if (node->last_heard != 0 && nodeAgeSecs > kNodeInfoMaxServeAgeSecs) { + TM_LOG_DEBUG("NodeInfo NodeDB entry for 0x%08x stale (%us ago), not responding", p->to, + static_cast(nodeAgeSecs)); + return false; + } +#if TMM_NODEINFO_REPLAY_SIGNED_GATE + // Replay provenance gate (fallback path): only vouch for a node NodeDB knows as a + // verified signer. An unproven (trust-on-first-use) identity is left for the genuine + // node or another cache-holder to answer. + if (!nodeInfoLiteHasXeddsaSigned(node)) { + TM_LOG_DEBUG("NodeInfo NodeDB entry for 0x%08x not signer-proven, not responding", p->to); + return false; + } +#endif cachedUser = TypeConversions::ConvertToUser(node); + usedFallback = true; } + // Staleness gate (cache path): never spoof a reply for a node not actually HEARD within + // the serve window. hasObserved distinguishes genuinely observed entries from seeded ones, + // and the sweep's saturation keeps this modular tick compare alias-free. + if (hasCachedUser && + (!cachedHasObserved || static_cast(currentObsTick() - cachedObsTick) > kNodeInfoMaxServeAgeTicks)) { + TM_LOG_DEBUG("NodeInfo cache entry for 0x%08x not freshly observed, not responding", p->to); + return false; + } + +#if TMM_NODEINFO_REPLAY_SIGNED_GATE + // Replay provenance gate (cache path): only spoof a reply for a signer-proven cached key. + // usedFallback entries were already gated above. See TMM_NODEINFO_REPLAY_REQUIRE_SIGNED. + if (!usedFallback && !cachedKeySignerProven) { + TM_LOG_DEBUG("NodeInfo cache entry for 0x%08x not signer-proven, not responding", p->to); + return false; + } +#endif + if (!sendResponse) return true; + // Throttle the spoofed reply (per requester + per target + 1 s global floor; checked here so a + // request declined above never spends the budget). false forwards the request instead of consuming + // it. Rationale in docs/traffic_management_module.md "Throttling direct responses". + if (!directResponseAllowed(getFrom(p), p->to, clockMs())) { + TM_LOG_DEBUG("NodeInfo direct response throttled for 0x%08x; forwarding request instead", getFrom(p)); + return false; + } + meshtastic_MeshPacket *reply = router->allocForSending(); if (!reply) { TM_LOG_WARN("NodeInfo direct response dropped: no packet buffer"); @@ -1127,7 +1514,7 @@ bool TrafficManagementModule::shouldRespondToNodeInfo(const meshtastic_MeshPacke reply->decoded.want_response = false; // Start from cached bitfield metadata when available. This lets direct - // responses preserve more of the original packet semantics (PSRAM path), + // responses preserve more of the original packet semantics (cache path), // while still enforcing local policy for OK_TO_MQTT below. if (cachedHasDecodedBitfield) reply->decoded.bitfield = cachedDecodedBitfield; @@ -1143,11 +1530,10 @@ bool TrafficManagementModule::shouldRespondToNodeInfo(const meshtastic_MeshPacke if (config.lora.config_ok_to_mqtt) reply->decoded.bitfield |= BITFIELD_OK_TO_MQTT_MASK; - if (hasCachedUser && cachedLastObservedMs != 0) { - uint32_t ageMs = clockMs() - cachedLastObservedMs; - TM_LOG_DEBUG("NodeInfo PSRAM hit node=0x%08x age=%lu ms src_ch=%u req_ch=%u rx_time=%lu", p->to, - static_cast(ageMs), static_cast(cachedSourceChannel), - static_cast(p->channel), static_cast(cachedLastObservedRxTime)); + if (hasCachedUser) { + TM_LOG_DEBUG("NodeInfo cache hit node=0x%08x age=%u obs-ticks src_ch=%u req_ch=%u", p->to, + static_cast(static_cast(currentObsTick() - cachedObsTick)), + static_cast(cachedSourceChannel), static_cast(p->channel)); } // Spoof the sender as the target node so the requestor sees a valid NodeInfo response. @@ -1164,6 +1550,58 @@ bool TrafficManagementModule::shouldRespondToNodeInfo(const meshtastic_MeshPacke reply->priority = meshtastic_MeshPacket_Priority_DEFAULT; service->sendToMesh(reply); + + // The throttle state was already recorded by directResponseAllowed() above (it stamps only when + // it admits a reply), so there is nothing to stamp here. + + return true; +} + +TrafficManagementModule::DirectResponseThrottleEntry * +TrafficManagementModule::directResponseSlot(DirectResponseThrottleEntry *table, NodeNum key, uint32_t nowMs, uint32_t windowMs) +{ + // Known key still inside its window -> throttled (nullptr). Outside it -> reuse that slot. + for (size_t i = 0; i < kDirectResponseTrackedNodes; i++) { + if (table[i].key == key) + return ((nowMs - table[i].lastReplyMs) < windowMs) ? nullptr : &table[i]; + } + // Unseen key: take a free slot, otherwise evict the least recently used one. + DirectResponseThrottleEntry *slot = &table[0]; + for (size_t i = 0; i < kDirectResponseTrackedNodes; i++) { + if (table[i].key == 0) + return &table[i]; + if (table[i].lastReplyMs < slot->lastReplyMs) + slot = &table[i]; + } + return slot; +} + +bool TrafficManagementModule::directResponseAllowed(NodeNum requester, NodeNum target, uint32_t nowMs) +{ + // Reached from the packet path (and shares state with the cache), so take the same lock. + concurrency::LockGuard guard(&cacheLock); + + // Global airtime floor first: cheapest, and the backstop once an attacker cycles requester/target + // past the 8-slot tables. + if (lastDirectResponseMs != 0 && (nowMs - lastDirectResponseMs) < kDirectResponseGlobalMs) + return false; + + // Resolve both slots before stamping either, so a reply the target axis throttles does not consume + // the requester's budget (and vice versa). + DirectResponseThrottleEntry *reqSlot = + directResponseSlot(directRequesterSeen, requester, nowMs, kDirectResponsePerRequesterMs); + if (reqSlot == nullptr) + return false; + DirectResponseThrottleEntry *tgtSlot = directResponseSlot(directTargetSeen, target, nowMs, kDirectResponsePerTargetMs); + if (tgtSlot == nullptr) + return false; + + // All windows clear: admit the reply and record it on every axis. + reqSlot->key = requester; + reqSlot->lastReplyMs = nowMs; + tgtSlot->key = target; + tgtSlot->lastReplyMs = nowMs; + lastDirectResponseMs = nowMs; return true; } @@ -1222,9 +1660,9 @@ bool TrafficManagementModule::isRateLimited(NodeNum from, uint32_t nowMs) } // Increment counter, saturating at 63 (6-bit field max). - const uint8_t cur = entry->getRateCount(); - if (cur < 0x3F) - entry->setRateCount(static_cast(cur + 1)); + const uint8_t currentCount = entry->getRateCount(); + if (currentCount < 0x3F) + entry->setRateCount(static_cast(currentCount + 1)); // Threshold capped at 60 so a saturated reading (63) always exceeds it. uint32_t threshold = moduleConfig.traffic_management.rate_limit_max_packets; @@ -1268,12 +1706,11 @@ bool TrafficManagementModule::shouldDropUnknown(const meshtastic_MeshPacket *p, entry->setUnknownCount(0); } - // Increment counter, saturating at 63 (6-bit field max). With threshold - // capped at 60, a saturated reading always exceeds the limit - no special - // already-saturated edge case needed. - const uint8_t cur = entry->getUnknownCount(); - if (cur < 0x3F) - entry->setUnknownCount(static_cast(cur + 1)); + // Increment counter, saturating at 63 (6-bit field max). With the threshold capped at + // 60, a saturated reading always exceeds the limit - no special edge case needed. + const uint8_t currentCount = entry->getUnknownCount(); + if (currentCount < 0x3F) + entry->setUnknownCount(static_cast(currentCount + 1)); // Threshold capped at 60 so a saturated reading (63) always exceeds it. uint32_t threshold = moduleConfig.traffic_management.unknown_packet_threshold; diff --git a/src/modules/TrafficManagementModule.h b/src/modules/TrafficManagementModule.h index 864b542bf..83d5ff60e 100644 --- a/src/modules/TrafficManagementModule.h +++ b/src/modules/TrafficManagementModule.h @@ -8,25 +8,32 @@ #if HAS_TRAFFIC_MANAGEMENT -/** - * TrafficManagementModule - Packet inspection and traffic shaping for mesh networks. - * - * This module provides: - * - Position deduplication (drop redundant position broadcasts) - * - Per-node rate limiting (throttle chatty nodes) - * - Unknown packet filtering (drop undecoded packets from repeat offenders) - * - NodeInfo direct response (answer queries from cache to reduce mesh chatter) - * - Local-only telemetry/position (exhaust hop_limit for local broadcasts) - * - Router hop preservation (maintain hop_limit for router-to-router traffic) - * - * Memory Optimization: - * Uses one flat unified cache (plain array, linear scan) shared by all - * per-node features instead of separate per-feature caches. Timestamps are - * stored as free-running modular tick counters (pos: 8-bit 360 s/tick; - * rate+unknown: paired 4-bit nibbles in one byte) for a 10-byte entry. - * LoRa packet rates are low enough that an O(n) scan of ~1000 entries is - * negligible next to packet processing. - */ +// Replay provenance gate: when 1 (default), direct responses are spoofed only for nodes whose +// cached key is signer-proven (XEdDSA-verified), not for trust-on-first-use identities. +// Define as 0 to also serve fresh TOFU-only nodes; bypassed entirely when PKI is excluded. +#ifndef TMM_NODEINFO_REPLAY_REQUIRE_SIGNED +#define TMM_NODEINFO_REPLAY_REQUIRE_SIGNED 1 +#endif + +// Effective gate: only meaningful when PKI is compiled in. +#if TMM_NODEINFO_REPLAY_REQUIRE_SIGNED && !(MESHTASTIC_EXCLUDE_PKI) +#define TMM_NODEINFO_REPLAY_SIGNED_GATE 1 +#else +#define TMM_NODEINFO_REPLAY_SIGNED_GATE 0 +#endif + +// NodeInfo cache availability. Production home is ESP32+PSRAM (the 2000-entry array is too big +// for MCU internal RAM); native unit-test builds enable it on the plain heap so the cache paths +// run in CI (tests needing the NodeDB fallback call dropNodeInfoCacheForTest()). +#if (defined(ARCH_ESP32) && defined(BOARD_HAS_PSRAM)) || (defined(ARCH_PORTDUINO) && defined(PIO_UNIT_TESTING)) +#define TMM_HAS_NODEINFO_CACHE 1 +#else +#define TMM_HAS_NODEINFO_CACHE 0 +#endif + +/// Packet inspection and traffic shaping: position dedup, per-node rate limiting, unknown-packet +/// filtering, NodeInfo direct response, and the next-hop/role overflow caches. One flat 10-byte +/// unified cache backs all per-node features; see docs/node_info_stores.md for the store overview. class TrafficManagementModule : public MeshModule, private concurrency::OSThread { public: @@ -37,43 +44,66 @@ class TrafficManagementModule : public MeshModule, private concurrency::OSThread TrafficManagementModule(const TrafficManagementModule &) = delete; TrafficManagementModule &operator=(const TrafficManagementModule &) = delete; + /// Snapshot of the module's counters (thread-safe). meshtastic_TrafficManagementStats getStats() const; + /// Zero all counters (thread-safe). void resetStats(); + /// Placeholder for the removed router_preserve_hops stat. void recordRouterHopPreserved(); - // Next-hop overflow cache (routing hint). - // setNextHop: store a confirmed last-byte next hop for `dest`. Called by - // NextHopRouter from its ACK-confirmed decision (see sniffReceived). The - // byte must come from a bidirectionally-verified relay, not one-way inference. - // getNextHopHint: return the cached next-hop byte for `dest`, 0 if unknown. - // clearNextHop: forget any cached next hop for `dest` (setNextHop refuses to store - // 0, so this is the way NextHopRouter decays a stale/failing overflow route). + /// Store a confirmed last-byte next hop for `dest`. Called only from NextHopRouter's + /// ACK-confirmed decision - the byte must come from a bidirectionally-verified relay. void setNextHop(NodeNum dest, uint8_t nextHopByte); + /// Cached next-hop byte for `dest`, 0 if unknown. uint8_t getNextHopHint(NodeNum dest); + /// Forget the cached next hop for `dest` (how NextHopRouter decays a failing route). void clearNextHop(NodeNum dest); - // Warm-start the next-hop cache from persisted NodeInfoLite hints so confirmed - // hops survive later hot-store (NodeDB) eviction. Idempotent; runs once after - // nodeDB is populated (lazily on first maintenance pass). - // @return true if it actually ran (prereqs met / nothing to do); false if - // prerequisites (cache, nodeDB) weren't ready yet, so the caller should retry. + /// Warm-start the next-hop cache from persisted NodeInfoLite hints so confirmed hops survive + /// hot-store eviction. @return true if it ran; false if prerequisites (cache, nodeDB) weren't + /// ready and the caller should retry on a later pass. bool preloadNextHopsFromNodeDB(); - /** - * Check if this packet should have its hops exhausted. - * Called from perhapsRebroadcast() to force hop_limit = 0 regardless of - * router_preserve_hops or favorite node logic. - */ + /// Last-resort key source for NodeDB::copyPublicKey() after the hot and warm tiers miss. + /// Copies the 32-byte key for `node` into out[32]; `signerProven` (optional) reports whether + /// the key was XEdDSA-verified vs trust-on-first-use. Thread-safe. + bool copyPublicKey(NodeNum node, uint8_t out[32], bool *signerProven = nullptr) const; + + /// Copy the full cached User for `node` (used by NodeDB to rehydrate a re-admitted node's + /// name - the warm tier keeps keys but not names). False on miss or key-only records. + /// `signerProven` (optional) reports the cached key's provenance. Thread-safe. + bool copyUser(NodeNum node, meshtastic_User &out, bool *signerProven = nullptr) const; + + /// Write-through hook from NodeDB::updateUser(): upsert the committed identity immediately + /// (the reconcile sweep remains the backstop). NodeDB's key is authoritative, but a keyless + /// commit keeps a TOFU key this cache already holds; never touches the observation stamp. + /// No-op while the module is disabled in moduleConfig (maintenance is gated the same way). + void onNodeIdentityCommitted(NodeNum node, const meshtastic_User &user, bool signerKnown); + + /// Key-only commit hook for key writes that bypass updateUser (admin-key learn, manual key + /// verification). A changed key resets provenance; pass proven=true only when the commit + /// itself established possession. Never touches the observation stamp. Thread-safe. + /// No-op while the module is disabled in moduleConfig (maintenance is gated the same way). + void onNodeKeyCommitted(NodeNum node, const uint8_t key32[32], bool proven); + + /// Zero one node's slots in both caches (identity, key, provenance, role, next-hop, dedup + /// state). Called by NodeDB removal so no TMM tier resurrects a deliberately deleted node; + /// passive eviction is unaffected. Thread-safe. + void purgeNode(NodeNum node); + /// Clear both cache tables outright (resetNodes / factory reset). Thread-safe. + void purgeAll(); + + /// True when perhapsRebroadcast() must force hop_limit=0 for this packet, regardless of + /// router_preserve_hops or favorite-node logic (set by alterReceived()). bool shouldExhaustHops(const meshtastic_MeshPacket &mp) const { return exhaustRequested && exhaustRequestedFrom == getFrom(&mp) && exhaustRequestedId == mp.id; } - // Injectable monotonic clock (ms). All TMM time reads go through clockMs() so unit tests can - // advance a virtual timebase instead of sleeping real seconds across the 6 min/360 s tick. - // Mirrors HopScalingModule::s_testNowMs. Writable from tests as TrafficManagementModule::s_testNowMs; - // ignored in production (clockMs() returns millis()). + // Injectable monotonic clock (ms): tests advance s_testNowMs instead of sleeping across + // ticks (mirrors HopScalingModule); production reads millis(). inline static uint32_t s_testNowMs = 0; + /// Monotonic module clock in ms (virtual under PIO_UNIT_TESTING). #ifdef PIO_UNIT_TESTING static uint32_t clockMs() { return s_testNowMs; } #else @@ -81,43 +111,40 @@ class TrafficManagementModule : public MeshModule, private concurrency::OSThread #endif protected: + /// Inspect a received packet; may consume it (STOP) for dedup/rate/unknown/direct-response. ProcessMessage handleReceived(const meshtastic_MeshPacket &mp) override; + /// Promiscuous: this module inspects every packet. bool wantPacket(const meshtastic_MeshPacket *p) override { return true; } + /// Mutate relayed packets in place (position precision clamp). void alterReceived(meshtastic_MeshPacket &mp) override; + /// 60 s maintenance sweep: expire timed state, saturate tick stamps, reconcile with NodeDB. int32_t runOnce() override; - // Protected so test shims can flush per-node traffic state. + /// Clear all per-node traffic state (protected for test shims). void flushCache(); - // Introspection for tests: the cached device role for a node, or -1 if the node has - // no cache entry (distinguishes "not tracked / evicted" from CLIENT == 0). + /// Test introspection: the cached role for `node`, or -1 when it has no entry + /// (distinguishes "not tracked" from CLIENT == 0). int peekCachedRole(NodeNum node); + /// Test hook: force a cached NodeInfo entry's key to signer-proven so replay-gate tests + /// can skip a full XEdDSA verification. No-op if absent. + void markKeySignerProvenForTest(NodeNum node); + + /// Test hook: free the NodeInfo cache so the NodeDB fallback path can be exercised in + /// builds where the cache is compiled in. No-op when already absent. + void dropNodeInfoCacheForTest(); + + /// Test introspection: NodeInfo flag bits for `node` (-1 if absent): bit0 hasObserved, + /// bit1 isMember, bit2 hasFullUser, bit3 keySignerProven. + int peekNodeInfoFlagsForTest(NodeNum node); + + /// Test introspection: NodeInfo cache capacity (kNodeInfoCacheEntries), so tests can + /// fill the cache exactly and force the tiered-LRU eviction paths. + static constexpr uint16_t nodeInfoCacheCapacityForTest() { return kNodeInfoCacheEntries; } + private: - // ========================================================================= - // Unified Cache Entry (10 bytes) - Same for ALL platforms - // ========================================================================= - // - // Layout: - // [0-3] node - NodeNum (4 bytes, 0 = empty slot) - // [4] pos_fingerprint - 4 bits lat + 4 bits lon (0 = no position seen) - // [5] rate_count - [7:6] role[3:2] | [5:0] packets in rate window (0 = no window active) - // [6] unknown_count - [7:6] role[1:0] | [5:0] unknown packets in window (0 = no window active) - // [7] pos_time - Position tick (uint8, free-running 360 s/tick) - // [8] rate_unknown_time - [7:4] rate nibble (300 s/tick) | [3:0] unknown nibble (60 s/tick) - // [9] next_hop - Last-byte relay to reach `node` (0 = none) - // - // The 4-bit device role (bits [7:6] of rate_count paired with [7:6] of unknown_count) - // caches the sender's meshtastic_Config_DeviceConfig_Role as a third fallback after the - // hot store and warm store, for nodes evicted from both. Read/written via - // resolveSenderRole(). Max encodable value is 15. - // - // Presence sentinels (no epoch, no +1 offset needed): - // pos active: pos_fingerprint != 0 - // rate active: getRateCount() != 0 (low 6 bits only) - // unknown active: getUnknownCount() != 0 (low 6 bits only) - // - // next_hop: routing hint written only from ACK-confirmed NextHopRouter decisions. - // No TTL - keeps the slot alive across maintenance sweeps. - // + // 10-byte packed entry, all platforms. Tick stamps are free-running modular counters with + // non-zero presence sentinels; the 4-bit cached role rides the top bits of the two count + // bytes (tier-3 role fallback). Full layout and rationale: docs/node_info_stores.md. #if _meshtastic_Config_DeviceConfig_Role_MAX > 15 #warning "Device role enum max exceeds 15 - TMM 4-bit role cache (rate_count[7:6]/unknown_count[7:6]) will truncate new values" #endif @@ -130,80 +157,72 @@ class TrafficManagementModule : public MeshModule, private concurrency::OSThread uint8_t rate_unknown_time; uint8_t next_hop; + /// Packets seen in the current rate window (low 6 bits). uint8_t getRateCount() const { return rate_count & 0x3F; } + /// Set the rate-window count, preserving the role bits. void setRateCount(uint8_t c) { rate_count = static_cast((rate_count & 0xC0) | (c & 0x3F)); } + /// Unknown packets seen in the current window (low 6 bits). uint8_t getUnknownCount() const { return unknown_count & 0x3F; } + /// Set the unknown-window count, preserving the role bits. void setUnknownCount(uint8_t c) { unknown_count = static_cast((unknown_count & 0xC0) | (c & 0x3F)); } + /// Cached 4-bit device role, reassembled from the two count bytes' top bits. uint8_t getCachedRole() const { return static_cast(((rate_count >> 6) << 2) | (unknown_count >> 6)); } + /// Store a 4-bit device role across the two count bytes' top bits. void setCachedRole(uint8_t role) { rate_count = static_cast((rate_count & 0x3F) | ((role >> 2) << 6)); unknown_count = static_cast((unknown_count & 0x3F) | ((role & 0x03) << 6)); } + /// Rate-window tick nibble. uint8_t getRateTime() const { return (rate_unknown_time >> 4) & 0x0F; } + /// Unknown-window tick nibble. uint8_t getUnknownTime() const { return rate_unknown_time & 0x0F; } + /// Set the rate-window tick nibble. void setRateTime(uint8_t t) { rate_unknown_time = static_cast((rate_unknown_time & 0x0F) | ((t & 0x0F) << 4)); } + /// Set the unknown-window tick nibble. void setUnknownTime(uint8_t t) { rate_unknown_time = static_cast((rate_unknown_time & 0xF0) | (t & 0x0F)); } }; static_assert(sizeof(UnifiedCacheEntry) == 10, "UnifiedCacheEntry should be 10 bytes"); - // ========================================================================= - // Flat unified cache - // ========================================================================= - // - // Plain array, linear scan (same idiom as WarmNodeStore). A lookup walks at - // most cacheSize() ร— 10 B - microseconds at LoRa packet rates, not worth a - // hash table. Insertion on a full cache evicts the stalest entry, - // preferring entries without a next_hop hint (those are the long-tail - // routing state this cache exists to keep). - // + /// Unified cache capacity. Plain array, linear scan (same idiom as WarmNodeStore); insertion + /// on a full cache evicts the stalest entry, preferring ones without a next_hop hint. static constexpr uint16_t cacheSize() { return TRAFFIC_MANAGEMENT_CACHE_SIZE; } - // NodeInfo cache configuration (PSRAM path): a flat PSRAM array of payload - // entries, linear scan keyed by `node`, LRU eviction by lastObservedMs. - // NodeInfo traffic is low-rate, so a full scan per lookup/insert is fine. + // NodeInfo cache (PSRAM-backed on hardware, heap in native tests): flat payload array, + // linear scan, trust/membership-tiered LRU eviction on insert. NodeInfo traffic is + // low-rate, so full scans are fine. static constexpr uint16_t kNodeInfoCacheEntries = 2000; + /// NodeInfo cache capacity. static constexpr uint16_t nodeInfoTargetEntries() { return kNodeInfoCacheEntries; } - // ========================================================================= - // Free-Running Tick Counters - // ========================================================================= - // - // Timestamps are stored as free-running modular tick counters derived from - // millis(). No epoch anchor needed: modular subtraction gives correct age - // as long as the true age stays below the counter period. - // - // pos_time : uint8 (256 ticks ร— 360 s = 25.6 h period; max window 12 h = 120 ticks) - // rate_time : nibble (16 ticks ร— 300 s = 80 min period; max window 1 h = 12 ticks) - // unknown_time: nibble (16 ticks ร— 60 s = 16 min period; max window 12 min = 12 ticks) - // - // Presence sentinels (no +1 offset needed; count fields serve as guards): - // pos active: pos_fingerprint != 0 (0 is reserved sentinel; computePositionFingerprint() remaps computed-0 โ†’ 0xFF) - // rate active: getRateCount() != 0 (low 6 bits; high 2 bits are cached role) - // unknown active: getUnknownCount() != 0 - // - static constexpr uint32_t kPosTimeTickMs = 360'000UL; // 6 min/tick - static constexpr uint32_t kRateTimeTickMs = 300'000UL; // 5 min/tick - static constexpr uint32_t kUnknownTimeTickMs = 60'000UL; // 1 min/tick + // Free-running modular tick clocks derived from clockMs(); modular subtraction gives correct + // age while true age stays below the counter period. Presence is carried by non-zero + // sentinels (unified cache) or explicit validity bits (NodeInfo cache). + static constexpr uint32_t kPosTimeTickMs = 360'000UL; // 6 min/tick (uint8: 25.6 h period) + static constexpr uint32_t kRateTimeTickMs = 300'000UL; // 5 min/tick (nibble: 80 min period) + static constexpr uint32_t kUnknownTimeTickMs = 60'000UL; // 1 min/tick (nibble: 16 min period) + /// Current position-clock tick (6 min/tick). static uint8_t currentPosTick() { return static_cast(clockMs() / kPosTimeTickMs); } + /// Current rate-clock tick nibble (5 min/tick). static uint8_t currentRateTick() { return static_cast((clockMs() / kRateTimeTickMs) & 0x0F); } + /// Current unknown-clock tick nibble (1 min/tick). static uint8_t currentUnknownTick() { return static_cast((clockMs() / kUnknownTimeTickMs) & 0x0F); } - // ========================================================================= - // Position Fingerprint - // ========================================================================= - // - // Computes 8-bit fingerprint from truncated lat/lon coordinates. - // Extracts lower 4 significant bits from each coordinate. - // - // fingerprint = (lat_low4 << 4) | lon_low4 - // - // Unlike a hash, adjacent grid cells have sequential fingerprints, - // so nearby positions never collide. Collisions only occur for - // positions 16+ grid cells apart in both dimensions. - // - // Guards: If precision < 4 bits, uses min(precision, 4) bits. - // + + // NodeInfo observation tick (same idiom). The 60 s sweep clears the presence bit once the serve + // window passes, so the stamp is never read near its uint8 aliasing horizon. (Response throttling + // no longer lives here - it is the fixed per-requester/per-target RAM tables below, which use + // wrap-safe uint32 ms compares and so need no tick clock or sweep.) + static constexpr uint32_t kNodeInfoObsTickMs = 180000UL; // 3 min/tick (12.8 h period) + static constexpr uint8_t kNodeInfoMaxServeAgeTicks = 120; // 6 h serve window + + /// Current NodeInfo observation tick (3 min/tick). + static uint8_t currentObsTick() { return static_cast(clockMs() / kNodeInfoObsTickMs); } + static_assert(kNodeInfoMaxServeAgeTicks * kNodeInfoObsTickMs == 6UL * 60UL * 60UL * 1000UL, + "cache serve window must equal the fallback path's 6 h"); + + /// 8-bit position fingerprint from truncated lat/lon: low 4 significant bits of each, so + /// adjacent grid cells never collide (collisions need 16+ cells apart in both dimensions). static uint8_t computePositionFingerprint(int32_t lat_truncated, int32_t lon_truncated, uint8_t precision); // ========================================================================= @@ -215,42 +234,60 @@ class TrafficManagementModule : public MeshModule, private concurrency::OSThread bool cacheFromPsram = false; // Tracks allocator for correct deallocation struct NodeInfoPayloadEntry { - // Node identifier associated with this payload slot. - // 0 means the slot is currently unused. + // Node identifier for this slot; 0 means unused. NodeNum node; - // Cached NODEINFO_APP payload body. This is separate from NodeDB and is only - // used by the PSRAM-backed direct-response path in this module. + // Cached NODEINFO_APP payload, independent of NodeDB; serves the PSRAM-backed + // direct-response path and the last-resort pubkey pool. meshtastic_User user; - // Extra response metadata captured from the latest observed NODEINFO_APP - // packet for this node. shouldRespondToNodeInfo() uses this metadata when - // building spoofed replies for requesting clients. - - // Last local uptime tick (millis) when this entry was refreshed. - uint32_t lastObservedMs; - - // Last RTC/packet timestamp (seconds) observed for this NodeInfo frame. - // If unavailable in packet, remains 0. - uint32_t lastObservedRxTime; + // Tick of the last genuinely HEARD NODEINFO frame (kNodeInfoObsTickMs clock). Drives the + // replay staleness gate and LRU age; seeding/write-through never touch it, so a spoofed + // reply is only ever backed by genuine recent observation. Validity: hasObserved. + uint8_t obsTick; // Channel where we most recently heard this node's NodeInfo. uint8_t sourceChannel; - // Cached decoded bitfield metadata from the source packet. - // We preserve non-OK_TO_MQTT bits in direct replies when available. - bool hasDecodedBitfield; + // Cached decoded bitfield from the source packet (non-OK_TO_MQTT bits are preserved + // in direct replies). Validity: hasDecodedBitfield. uint8_t decodedBitfield; - }; - NodeInfoPayloadEntry *nodeInfoPayload = nullptr; // NodeInfo payloads in PSRAM (flat array, linear scan) + // 1-bit flags, packed into one byte (6 spare bits; add future booleans here rather + // than new bytes - the array is 2000 entries). + + // The source packet carried a decoded bitfield (so decodedBitfield is meaningful). + uint8_t hasDecodedBitfield : 1; + + // Key provenance: set once an XEdDSA signature was verified for user.public_key + // (directly, or inherited from NodeDB via isVerifiedSignerForKey). Monotonic per slot; + // the key-pin checks forbid the key changing underneath it. TOFU keys start at 0. + uint8_t keySignerProven : 1; + + // obsTick is valid: a NODEINFO frame was actually heard within the observation clock's + // horizon. Cleared by the sweep once the serve window passes (saturation). + uint8_t hasObserved : 1; + + // `user` carries a real User payload (from an observed frame or hot-store seed) rather + // than a key-only warm-tier record. copyUser()/name-rehydration require it. + uint8_t hasFullUser : 1; + + // Node currently exists in NodeDB (hot or warm), per the last hourly reconcile pass + // (write-through hooks set it immediately on commit; purgeNode clears immediately on + // removal; a passive NodeDB eviction may lag up to an hour). Member entries are + // stickiest under LRU; the bit is the keep-alive (no TTL). + uint8_t isMember : 1; + }; + // No exact-size static_assert: sizeof(meshtastic_User) and its padding vary by platform, so + // any fixed byte count would fail the build on some boards. + + NodeInfoPayloadEntry *nodeInfoPayload = nullptr; // NodeInfo payloads (flat array; PSRAM on hardware, heap in tests) bool nodeInfoPayloadFromPsram = false; // Tracks allocator for correct deallocation meshtastic_TrafficManagementStats stats; - // Flag set during alterReceived() when packet should be exhausted. - // Checked by perhapsRebroadcast() to force hop_limit = 0 only for the - // matching packet key (from + id). Reset at start of handleReceived(). + // Set during alterReceived() when the packet's hops should be exhausted; checked by + // perhapsRebroadcast() for the matching packet key. Reset at start of handleReceived(). bool exhaustRequested = false; NodeNum exhaustRequestedFrom = 0; PacketId exhaustRequestedId = 0; @@ -258,48 +295,97 @@ class TrafficManagementModule : public MeshModule, private concurrency::OSThread // One-shot guard: warm-start next-hop cache from NodeDB on first maintenance pass. bool nextHopPreloaded = false; + // Reconcile cadence: full boot seed on the first maintenance pass, then hourly. The + // write-through hooks give immediacy; this periodic repair self-heals anything they miss. + static constexpr uint8_t kNodeInfoReconcileSweeps = 60; // sweeps between reconciliations (60 x 60 s = 1 h) + bool nodeInfoSeeded = false; + uint8_t sweepsSinceNodeInfoReconcile = 0; + // ========================================================================= // Cache Operations // ========================================================================= - // Find or create entry for node (linear scan; stalest-first eviction when full) + /// Find or create the unified-cache entry for `node` (stalest-first eviction when full). UnifiedCacheEntry *findOrCreateEntry(NodeNum node, bool *isNew); - // Find existing entry (no creation) + /// Find an existing unified-cache entry (no creation). UnifiedCacheEntry *findEntry(NodeNum node); - // Resolve a sender's advertised device role for the position hot path. The tier-3 - // cache (this entry's getCachedRole) is authoritative and is kept fresh by - // updateCachedRoleFromNodeInfo() - updated when NodeDB learns a role, not re-derived - // per packet. Only on first tracking (isNew) do we scan NodeDB (hot store โ†’ warm - // store, via getNodeRole) to seed the cache, so a resident special-role node is - // correct from its first position; after that the read is O(1) and survives the node - // aging out of both NodeDB stores. Caller must hold cacheLock; entry may be null - // (โ†’ NodeDB scan only). + /// Resolve a sender's device role for the position hot path. The tier-3 cache is + /// authoritative once seeded (NodeDB is scanned only on first tracking), so the read is O(1) + /// and survives the node aging out of both NodeDB stores. Caller must hold cacheLock. meshtastic_Config_DeviceConfig_Role resolveSenderRole(NodeNum from, UnifiedCacheEntry *entry, bool isNew); - // Refresh the tier-3 role cache from an observed NodeInfo (the same event that updates - // NodeDB's role). Reads role from the packet's User payload; updates only nodes already - // tracked (no entry creation). Takes cacheLock. + /// Refresh the tier-3 role cache from an observed NodeInfo (the same event that updates + /// NodeDB's role). Updates only nodes already tracked. Takes cacheLock. void updateCachedRoleFromNodeInfo(const meshtastic_MeshPacket &mp); - // NodeInfo cache operations (flat PSRAM payload array, linear scan) + /// Find an existing NodeInfo cache entry (no creation). const NodeInfoPayloadEntry *findNodeInfoEntry(NodeNum node) const; - NodeInfoPayloadEntry *findOrCreateNodeInfoEntry(NodeNum node, bool *usedEmptySlot); + /// Mutable variant of findNodeInfoEntry(). + NodeInfoPayloadEntry *findNodeInfoEntryMutable(NodeNum node) + { + return const_cast(findNodeInfoEntry(node)); + } + /// Find or create a NodeInfo cache entry, evicting by trust/membership tier when full. + /// With spareMembers, returns nullptr instead of evicting an isMember entry (the seeding + /// pass never churns one NodeDB-tier node out for another; the packet path may). + NodeInfoPayloadEntry *findOrCreateNodeInfoEntry(NodeNum node, bool *usedEmptySlot, bool spareMembers = false); + /// Number of occupied NodeInfo cache slots. Caller must hold cacheLock. uint16_t countNodeInfoEntriesLocked() const; + + /// 60 s NodeInfo-cache maintenance under cacheLock: saturate the expired obsTick stamp (wrap-safety + /// for the modular clock) and run the boot/hourly reconcile. Guarded by TMM_HAS_NODEINFO_CACHE alone + /// (never the unified cache size); see docs/node_info_stores.md "Tick clocks and wrap safety". + void maintainNodeInfoCacheLocked(); + + /// Anti-entropy under cacheLock: upsert hot-store + warm-tier records this cache lacks (never sets + /// hasObserved - seeding is knowledge, not observation), and refresh isMember from both NodeDB + /// tiers. Cost/lag: docs/node_info_stores.md "Consistency with NodeDB (anti-entropy)". + void reconcileNodeInfoFromNodeDBLocked(); + /// Learn an observed NODEINFO frame into the cache (key hygiene + provenance rules apply). void cacheNodeInfoPacket(const meshtastic_MeshPacket &mp); // ========================================================================= // Traffic Management Logic // ========================================================================= + /// True when this position broadcast duplicates the sender's last one within the dedup window. bool shouldDropPosition(const meshtastic_MeshPacket *p, const meshtastic_Position *pos, uint32_t nowMs); + /// Decide (and with sendResponse, emit) a spoofed direct NodeInfo reply for a unicast request. bool shouldRespondToNodeInfo(const meshtastic_MeshPacket *p, bool sendResponse); + + // Direct-response throttles bounding the reflector risk of spoofed replies: three fixed bounds + // (per requester, per target, 1 s global airtime floor) via 8-slot LRU RAM tables, wrap-safe and + // PSRAM-agnostic. Design & rationale: docs/traffic_management_module.md "Throttling direct responses". + static constexpr uint32_t kDirectResponsePerRequesterMs = 60'000UL; + static constexpr uint32_t kDirectResponsePerTargetMs = 60'000UL; + static constexpr uint32_t kDirectResponseGlobalMs = 1'000UL; + static constexpr size_t kDirectResponseTrackedNodes = 8; + struct DirectResponseThrottleEntry { + NodeNum key; // requester or target node; 0 = unused slot + uint32_t lastReplyMs; // clockMs() of our last reply keyed on this node + }; + DirectResponseThrottleEntry directRequesterSeen[kDirectResponseTrackedNodes] = {}; + DirectResponseThrottleEntry directTargetSeen[kDirectResponseTrackedNodes] = {}; + uint32_t lastDirectResponseMs = 0; + /// True (and records the send) when a spoofed direct reply to `requester` for `target` is within + /// every throttle window; false throttles it. Caller must NOT hold cacheLock (this takes it). + bool directResponseAllowed(NodeNum requester, NodeNum target, uint32_t nowMs); + /// Slot in `table` to stamp for `key` at `nowMs`, or nullptr if `key` is still within `windowMs` + /// of its last reply (throttled). Does not stamp - the caller stamps only once all windows pass. + static DirectResponseThrottleEntry *directResponseSlot(DirectResponseThrottleEntry *table, NodeNum key, uint32_t nowMs, + uint32_t windowMs); + /// True when the requestor is within the role-clamped hop limit for direct responses. bool isMinHopsFromRequestor(const meshtastic_MeshPacket *p) const; + /// True when `from` exceeded the configured packet budget for the current rate window. bool isRateLimited(NodeNum from, uint32_t nowMs); + /// True when `p`'s sender exceeded the undecodable-packet threshold for the current window. bool shouldDropUnknown(const meshtastic_MeshPacket *p, uint32_t nowMs); + /// Log a traffic action (drop/respond/clamp) with port name and packet routing context. void logAction(const char *action, const meshtastic_MeshPacket *p, const char *reason) const; + /// Increment a stats counter under cacheLock. void incrementStat(uint32_t *field); }; diff --git a/src/modules/esp32/AudioModule.cpp b/src/modules/esp32/AudioModule.cpp index 815e426a2..0dc9832f3 100644 --- a/src/modules/esp32/AudioModule.cpp +++ b/src/modules/esp32/AudioModule.cpp @@ -258,6 +258,8 @@ bool AudioModule::shouldDraw() void AudioModule::sendPayload(NodeNum dest, bool wantReplies) { meshtastic_MeshPacket *p = allocReply(); + if (!p) + return; p->to = dest; p->decoded.want_response = wantReplies; diff --git a/src/modules/esp32/PaxcounterModule.cpp b/src/modules/esp32/PaxcounterModule.cpp index c9eca1c74..db38165c6 100644 --- a/src/modules/esp32/PaxcounterModule.cpp +++ b/src/modules/esp32/PaxcounterModule.cpp @@ -81,6 +81,8 @@ bool PaxcounterModule::sendInfo(NodeNum dest) pl.uptime = millis() / 1000; meshtastic_MeshPacket *p = allocDataProtobuf(pl); + if (!p) + return false; p->to = dest; p->decoded.want_response = false; p->priority = meshtastic_MeshPacket_Priority_BACKGROUND; diff --git a/src/modules/games/GamesModule.cpp b/src/modules/games/GamesModule.cpp index 1d72ba632..606d1d505 100644 --- a/src/modules/games/GamesModule.cpp +++ b/src/modules/games/GamesModule.cpp @@ -105,6 +105,8 @@ void GamesModule::announceHighScore(const char *initials, uint32_t score) if (!initials || initials[0] == '\0') return; meshtastic_MeshPacket *p = allocDataPacket(); + if (!p) + return; p->to = NODENUM_BROADCAST; p->channel = 0; // primary channel p->decoded.portnum = meshtastic_PortNum_TEXT_MESSAGE_APP; diff --git a/src/motion/BMI270Sensor.cpp b/src/motion/BMI270Sensor.cpp index bc547529d..9f3911956 100644 --- a/src/motion/BMI270Sensor.cpp +++ b/src/motion/BMI270Sensor.cpp @@ -444,16 +444,6 @@ bool BMI270Sensor::writeRegister(uint8_t reg, uint8_t value) return wire->endTransmission() == 0; } -bool BMI270Sensor::writeRegisters(uint8_t reg, const uint8_t *data, size_t len) -{ - wire->beginTransmission(deviceAddress()); - wire->write(reg); - for (size_t i = 0; i < len; i++) { - wire->write(data[i]); - } - return wire->endTransmission() == 0; -} - uint8_t BMI270Sensor::readRegister(uint8_t reg) { wire->beginTransmission(deviceAddress()); diff --git a/src/motion/BMI270Sensor.h b/src/motion/BMI270Sensor.h index 7d6cdeaa9..3addc4541 100644 --- a/src/motion/BMI270Sensor.h +++ b/src/motion/BMI270Sensor.h @@ -18,7 +18,6 @@ class BMI270Sensor : public MotionSensor // BMI270 register access bool writeRegister(uint8_t reg, uint8_t value); - bool writeRegisters(uint8_t reg, const uint8_t *data, size_t len); uint8_t readRegister(uint8_t reg); bool readRegisters(uint8_t reg, uint8_t *data, size_t len); diff --git a/src/motion/BMM150Sensor.cpp b/src/motion/BMM150Sensor.cpp index f48d20288..4f4bf26c0 100644 --- a/src/motion/BMM150Sensor.cpp +++ b/src/motion/BMM150Sensor.cpp @@ -4,7 +4,7 @@ #if !defined(MESHTASTIC_EXCLUDE_SCREEN) // screen is defined in main.cpp -extern graphics::Screen *screen; +extern std::unique_ptr screen; #endif BMM150Sensor::BMM150Sensor(ScanI2C::FoundDevice foundDevice) : MotionSensor::MotionSensor(foundDevice) {} diff --git a/src/motion/BMX160Sensor.cpp b/src/motion/BMX160Sensor.cpp index 863a23f57..7052f3982 100755 --- a/src/motion/BMX160Sensor.cpp +++ b/src/motion/BMX160Sensor.cpp @@ -8,7 +8,7 @@ BMX160Sensor::BMX160Sensor(ScanI2C::FoundDevice foundDevice) : MotionSensor::Mot #if !defined(MESHTASTIC_EXCLUDE_SCREEN) // screen is defined in main.cpp -extern graphics::Screen *screen; +extern std::unique_ptr screen; #endif bool BMX160Sensor::init() diff --git a/src/motion/ICM20948Sensor.cpp b/src/motion/ICM20948Sensor.cpp index 3dd5a5e46..e0fc2c5a8 100755 --- a/src/motion/ICM20948Sensor.cpp +++ b/src/motion/ICM20948Sensor.cpp @@ -4,7 +4,7 @@ #if !defined(MESHTASTIC_EXCLUDE_SCREEN) // screen is defined in main.cpp -extern graphics::Screen *screen; +extern std::unique_ptr screen; #endif // Flag when an interrupt has been detected diff --git a/src/motion/MMC5983MASensor.cpp b/src/motion/MMC5983MASensor.cpp index d63420d92..62fb1105e 100644 --- a/src/motion/MMC5983MASensor.cpp +++ b/src/motion/MMC5983MASensor.cpp @@ -6,7 +6,7 @@ #include "detect/ScanI2CTwoWire.h" #if !defined(MESHTASTIC_EXCLUDE_SCREEN) -extern graphics::Screen *screen; +extern std::unique_ptr screen; #endif static constexpr float MMC5983MA_ZERO_FIELD = 131072.0f; diff --git a/src/motion/MotionSensor.cpp b/src/motion/MotionSensor.cpp index 878b4dd50..b1744ad92 100755 --- a/src/motion/MotionSensor.cpp +++ b/src/motion/MotionSensor.cpp @@ -45,7 +45,7 @@ CompassAccelSample latestCompassAccelSample; } // namespace // screen is defined in main.cpp -extern graphics::Screen *screen; +extern std::unique_ptr screen; MotionSensor::MotionSensor(ScanI2C::FoundDevice foundDevice) { diff --git a/src/mqtt/MQTT.cpp b/src/mqtt/MQTT.cpp index 907888bff..6427b4c31 100644 --- a/src/mqtt/MQTT.cpp +++ b/src/mqtt/MQTT.cpp @@ -33,7 +33,11 @@ #include "IPAddress.h" #if defined(ARCH_PORTDUINO) +#if defined(_WIN32) +#include // ntohl() +#else #include +#endif #elif !defined(ntohl) #include #define ntohl __ntohl @@ -55,6 +59,32 @@ static bool isConnected = false; static uint32_t lastPositionUnavailableWarning = 0; static const uint32_t POSITION_UNAVAILABLE_WARNING_INTERVAL_MS = 15000; // 15 seconds +inline bool shouldDropMqttDownlink(const meshtastic_MeshPacket &packet) +{ + if (is_in_repeated(config.lora.ignore_incoming, packet.from)) { + LOG_INFO("Drop MQTT ignored 0x%08x", packet.from); + return true; + } + + const meshtastic_NodeInfoLite *node = nodeDB->getMeshNode(packet.from); + if (nodeInfoLiteIsIgnored(node)) { + LOG_INFO("Drop MQTT node 0x%08x", packet.from); + return true; + } + + if (packet.from == NODENUM_BROADCAST) { + LOG_INFO("Drop MQTT broadcast src"); + return true; + } + + if (config.lora.ignore_mqtt) { + LOG_INFO("Drop MQTT ignore_mqtt"); + return true; + } + + return false; +} + inline void onReceiveProto(char *topic, byte *payload, size_t length) { const DecodedServiceEnvelope e(payload, length); @@ -91,8 +121,11 @@ inline void onReceiveProto(char *topic, byte *payload, size_t length) // receives it when we get our own packet back. Then we'll stop our retransmissions. if (isFromUs(e.packet)) { auto pAck = routingModule->allocAckNak(meshtastic_Routing_Error_NONE, getFrom(e.packet), e.packet->id, ch.index); + if (!pAck) + return; pAck->transport_mechanism = meshtastic_MeshPacket_TransportMechanism_TRANSPORT_MQTT; - router->sendLocal(pAck); + if (router->sendLocal(pAck) == ERRNO_SHOULD_RELEASE) + packetPool.release(pAck); } else { LOG_INFO("Ignore downlink message we originally sent"); } @@ -110,6 +143,8 @@ inline void onReceiveProto(char *topic, byte *payload, size_t length) } UniquePacketPoolPacket p = packetPool.allocUniqueZeroed(); + if (!p) + return; p->from = e.packet->from; p->to = e.packet->to; p->id = e.packet->id; @@ -117,11 +152,15 @@ inline void onReceiveProto(char *topic, byte *payload, size_t length) p->hop_limit = e.packet->hop_limit; p->hop_start = e.packet->hop_start; p->want_ack = e.packet->want_ack; - p->via_mqtt = true; // Mark that the packet was received via MQTT + p->via_mqtt = true; // Mark that the packet was received via MQTT + p->pki_encrypted = false; // Only local AES-CCM decryption may establish PKI authentication. p->transport_mechanism = meshtastic_MeshPacket_TransportMechanism_TRANSPORT_MQTT; p->which_payload_variant = e.packet->which_payload_variant; memcpy(&p->decoded, &e.packet->decoded, std::max(sizeof(p->decoded), sizeof(p->encrypted))); + if (shouldDropMqttDownlink(*p)) + return; + if (p->which_payload_variant == meshtastic_MeshPacket_decoded_tag) { if (moduleConfig.mqtt.encryption_enabled) { LOG_INFO("Ignore decoded message on MQTT, encryption is enabled"); @@ -139,12 +178,9 @@ inline void onReceiveProto(char *topic, byte *payload, size_t length) // impersonate a signing node with unsigned broadcasts. Hold cryptLock like the RF path // (perhapsDecode) does - checkXeddsaReceivePolicy -> xeddsa_verify mutates shared // CryptoEngine cache state, and MQTT ingress can run on a different task. - { - concurrency::LockGuard g(cryptLock); - if (!checkXeddsaReceivePolicy(p.get())) { - LOG_INFO("Ignore decoded message failing XEdDSA policy"); - return; - } + if (passesRoutingAuthGate(p.get()) != RoutingAuthVerdict::ACCEPT) { + LOG_INFO("Ignore decoded message failing XEdDSA policy"); + return; } #endif } @@ -157,8 +193,7 @@ inline void onReceiveProto(char *topic, byte *payload, size_t length) // likely they discovered each other via a channel we have downlink enabled for if (isToUs(p.get()) || (nodeInfoLiteHasUser(tx) && nodeInfoLiteHasUser(rx))) router->enqueueReceivedMessage(p.release()); - } else if (router && - perhapsDecode(p.get()) == DecodeState::DECODE_SUCCESS) // ignore messages if we don't have the channel key + } else if (router && passesRoutingAuthGate(p.get()) == RoutingAuthVerdict::ACCEPT) router->enqueueReceivedMessage(p.release()); } @@ -301,7 +336,20 @@ void MQTT::mqttCallback(char *topic, byte *payload, unsigned int length) void MQTT::onClientProxyReceive(meshtastic_MqttClientProxyMessage msg) { - onReceive(msg.topic, msg.payload_variant.data.bytes, msg.payload_variant.data.size); + // payload_variant is a union: on the text variant, data.size aliases the first bytes of the + // string, so reading it unconditionally let a client name any length up to PB_SIZE_MAX. + switch (msg.which_payload_variant) { + case meshtastic_MqttClientProxyMessage_data_tag: + onReceive(msg.topic, msg.payload_variant.data.bytes, msg.payload_variant.data.size); + break; + case meshtastic_MqttClientProxyMessage_text_tag: + onReceive(msg.topic, (byte *)msg.payload_variant.text, + strnlen(msg.payload_variant.text, sizeof(msg.payload_variant.text))); + break; + default: + LOG_WARN("MQTT proxy message carries no payload, topic %s", msg.topic); + break; + } } void MQTT::onReceive(char *topic, byte *payload, size_t length) diff --git a/src/platform/esp32/xtensa_swatomic.c b/src/platform/esp32/xtensa_swatomic.c new file mode 100644 index 000000000..8e905a275 --- /dev/null +++ b/src/platform/esp32/xtensa_swatomic.c @@ -0,0 +1,111 @@ +// Weak software __atomic_*_{1,2,4} for ESP32-S2/S3. -mdisable-hardware-atomics makes +// GCC emit these libcalls, but the precompiled libnewlib ships only the _8 variants and +// some toolchains' libgcc ships none, so S3 links fail on macOS. Weak = a real libgcc +// definition wins with no clash. Spinlock/critical-section like IDF's stdatomic.c. + +#include "sdkconfig.h" + +#if defined(CONFIG_IDF_TARGET_ESP32S3) || defined(CONFIG_IDF_TARGET_ESP32S2) + +#include +#include + +#include "freertos/FreeRTOS.h" + +// These names are GCC builtins; declaring them trips -Wbuiltin-declaration-mismatch +// (uint32_t vs GCC's unsigned int, same ABI). Suppressed as IDF's stdatomic.c does. +#pragma GCC diagnostic ignored "-Wbuiltin-declaration-mismatch" + +static portMUX_TYPE s_swatomic_mux = portMUX_INITIALIZER_UNLOCKED; + +#define SWATOMIC_ENTER() portENTER_CRITICAL_SAFE(&s_swatomic_mux) +#define SWATOMIC_EXIT() portEXIT_CRITICAL_SAFE(&s_swatomic_mux) + +// load / store / exchange / compare_exchange for one width. +#define GEN_SWATOMIC_CORE(N, TYPE) \ + __attribute__((weak, used)) TYPE __atomic_load_##N(const volatile void *ptr, int memorder) \ + { \ + (void)memorder; \ + SWATOMIC_ENTER(); \ + TYPE ret = *(const volatile TYPE *)ptr; \ + SWATOMIC_EXIT(); \ + return ret; \ + } \ + __attribute__((weak, used)) void __atomic_store_##N(volatile void *ptr, TYPE val, int memorder) \ + { \ + (void)memorder; \ + SWATOMIC_ENTER(); \ + *(volatile TYPE *)ptr = val; \ + SWATOMIC_EXIT(); \ + } \ + __attribute__((weak, used)) TYPE __atomic_exchange_##N(volatile void *ptr, TYPE val, int memorder) \ + { \ + (void)memorder; \ + SWATOMIC_ENTER(); \ + TYPE old = *(volatile TYPE *)ptr; \ + *(volatile TYPE *)ptr = val; \ + SWATOMIC_EXIT(); \ + return old; \ + } \ + __attribute__((weak, used)) bool __atomic_compare_exchange_##N(volatile void *ptr, void *expected, TYPE desired, \ + bool is_weak, int success, int failure) \ + { \ + (void)is_weak; \ + (void)success; \ + (void)failure; \ + bool ok; \ + SWATOMIC_ENTER(); \ + TYPE cur = *(volatile TYPE *)ptr; \ + if (cur == *(TYPE *)expected) { \ + *(volatile TYPE *)ptr = desired; \ + ok = true; \ + } else { \ + *(TYPE *)expected = cur; \ + ok = false; \ + } \ + SWATOMIC_EXIT(); \ + return ok; \ + } + +// A read-modify-write pair: fetch_ returns the old value, _fetch the +// new. EXPR is evaluated over `old` and `val`. +#define GEN_SWATOMIC_RMW(N, TYPE, NAME, EXPR) \ + __attribute__((weak, used)) TYPE __atomic_fetch_##NAME##_##N(volatile void *ptr, TYPE val, int memorder) \ + { \ + (void)memorder; \ + SWATOMIC_ENTER(); \ + TYPE old = *(volatile TYPE *)ptr; \ + *(volatile TYPE *)ptr = (TYPE)(EXPR); \ + SWATOMIC_EXIT(); \ + return old; \ + } \ + __attribute__((weak, used)) TYPE __atomic_##NAME##_fetch_##N(volatile void *ptr, TYPE val, int memorder) \ + { \ + (void)memorder; \ + SWATOMIC_ENTER(); \ + TYPE old = *(volatile TYPE *)ptr; \ + TYPE nv = (TYPE)(EXPR); \ + *(volatile TYPE *)ptr = nv; \ + SWATOMIC_EXIT(); \ + return nv; \ + } + +#define GEN_SWATOMIC_ALL(N, TYPE) \ + GEN_SWATOMIC_CORE(N, TYPE) \ + GEN_SWATOMIC_RMW(N, TYPE, add, old + val) \ + GEN_SWATOMIC_RMW(N, TYPE, sub, old - val) \ + GEN_SWATOMIC_RMW(N, TYPE, and, old &val) \ + GEN_SWATOMIC_RMW(N, TYPE, or, old | val) \ + GEN_SWATOMIC_RMW(N, TYPE, xor, old ^ val) \ + GEN_SWATOMIC_RMW(N, TYPE, nand, ~(old & val)) + +GEN_SWATOMIC_ALL(1, uint8_t) +GEN_SWATOMIC_ALL(2, uint16_t) +GEN_SWATOMIC_ALL(4, uint32_t) + +#else + +// Keep this a non-empty translation unit on targets that inline hardware atomics. +typedef int swatomic_translation_unit_not_empty; + +#endif // CONFIG_IDF_TARGET_ESP32S3 || CONFIG_IDF_TARGET_ESP32S2 diff --git a/src/platform/extra_variants/t5s3_epaper/variant.cpp b/src/platform/extra_variants/t5s3_epaper/variant.cpp index 9a38ddf73..6c1dacb64 100644 --- a/src/platform/extra_variants/t5s3_epaper/variant.cpp +++ b/src/platform/extra_variants/t5s3_epaper/variant.cpp @@ -36,7 +36,7 @@ class TouchInkHUDBridge : public Observer // Check whether a system applet (e.g. menu) is currently handling input bool systemHandlingInput = false; - for (NicheGraphics::InkHUD::SystemApplet *sa : inkhud->systemApplets) { + for (const NicheGraphics::InkHUD::SystemApplet *sa : inkhud->systemApplets) { if (sa->handleInput) { systemHandlingInput = true; break; diff --git a/src/platform/nrf52/NRF52Bluetooth.cpp b/src/platform/nrf52/NRF52Bluetooth.cpp index 357c1484c..f1d9c6845 100644 --- a/src/platform/nrf52/NRF52Bluetooth.cpp +++ b/src/platform/nrf52/NRF52Bluetooth.cpp @@ -7,6 +7,7 @@ #include "error.h" #include "main.h" #include "mesh/PhoneAPI.h" +#include "mesh/Throttle.h" #include "mesh/mesh-pb-constants.h" #include #include @@ -466,17 +467,23 @@ bool NRF52Bluetooth::onUnwantedPairing(uint16_t conn_handle, uint8_t const passk // Disconnect any BLE connections void NRF52Bluetooth::disconnect() { + static constexpr uint32_t DISCONNECT_TIMEOUT_MSEC = 1000; uint8_t connection_num = Bluefruit.connected(); if (connection_num) { // Close all connections. We're only expecting one. for (uint8_t i = 0; i < connection_num; i++) Bluefruit.disconnect(i); - // Wait for disconnection - while (Bluefruit.connected()) - yield(); + // Best-effort wait: on Bluefruit's BLE event task the DISCONNECTED event can't be processed + // until this callback returns, so an unbounded wait would deadlock until the watchdog fires. + uint32_t start = millis(); + while (Bluefruit.connected() && Throttle::isWithinTimespanMs(start, DISCONNECT_TIMEOUT_MSEC)) + delay(1); - LOG_INFO("Ended BLE connection"); + if (Bluefruit.connected()) + LOG_WARN("BLE disconnect unconfirmed after %ums, continuing shutdown", millis() - start); + else + LOG_INFO("Ended BLE connection"); } } diff --git a/src/platform/portduino/GpsdSerial.cpp b/src/platform/portduino/GpsdSerial.cpp index 038ba21f6..1b80c7d06 100644 --- a/src/platform/portduino/GpsdSerial.cpp +++ b/src/platform/portduino/GpsdSerial.cpp @@ -3,13 +3,92 @@ #include "GpsdSerial.h" #include "configuration.h" -#include #include + +#ifdef _WIN32 +#include +#include +#include +#else +#include #include #include #include #include #include +#endif + +namespace +{ +// Winsock needs explicit init, closesocket(), ioctlsocket() and WSAGetLastError(). +// SOCKET fits the header's `int` on Win64, and INVALID_SOCKET narrows to -1. +#ifdef _WIN32 +// Done lazily to keep the dependency local to the one file that needs it. +void initSocketsOnce() +{ + static std::once_flag flag; + std::call_once(flag, [] { + WSADATA wsaData; + WSAStartup(MAKEWORD(2, 2), &wsaData); + }); +} + +void closeSocket(int fd) +{ + ::closesocket(static_cast(fd)); +} + +void setNonBlocking(int fd) +{ + u_long mode = 1; + ::ioctlsocket(static_cast(fd), FIONBIO, &mode); +} + +// Winsock has no MSG_DONTWAIT, but the socket is already non-blocking so a plain +// recv() has the same semantics. +int recvNonBlocking(int fd, void *buf, size_t len) +{ + return ::recv(static_cast(fd), static_cast(buf), static_cast(len), 0); +} + +int sendAll(int fd, const void *buf, size_t len) +{ + return ::send(static_cast(fd), static_cast(buf), static_cast(len), 0); +} + +bool lastErrorWasWouldBlock() +{ + return WSAGetLastError() == WSAEWOULDBLOCK; +} +#else +void initSocketsOnce() {} + +void closeSocket(int fd) +{ + ::close(fd); +} + +void setNonBlocking(int fd) +{ + ::fcntl(fd, F_SETFL, O_NONBLOCK); +} + +int recvNonBlocking(int fd, void *buf, size_t len) +{ + return static_cast(::recv(fd, buf, len, MSG_DONTWAIT)); +} + +int sendAll(int fd, const void *buf, size_t len) +{ + return static_cast(::write(fd, buf, len)); +} + +bool lastErrorWasWouldBlock() +{ + return errno == EAGAIN || errno == EWOULDBLOCK; +} +#endif +} // namespace namespace arduino { @@ -28,6 +107,8 @@ bool GpsdSerial::connectToGpsd() if (_host.empty()) return false; + initSocketsOnce(); + struct addrinfo hints = {}, *res = nullptr; hints.ai_family = AF_UNSPEC; hints.ai_socktype = SOCK_STREAM; @@ -41,12 +122,12 @@ bool GpsdSerial::connectToGpsd() // Try every address returned by getaddrinfo (e.g. ::1 before 127.0.0.1). int fd = -1; for (struct addrinfo *rp = res; rp != nullptr; rp = rp->ai_next) { - fd = socket(rp->ai_family, rp->ai_socktype, rp->ai_protocol); + fd = static_cast(socket(rp->ai_family, rp->ai_socktype, rp->ai_protocol)); if (fd < 0) continue; - if (connect(fd, rp->ai_addr, rp->ai_addrlen) == 0) + if (connect(fd, rp->ai_addr, static_cast(rp->ai_addrlen)) == 0) break; // connected - ::close(fd); + closeSocket(fd); fd = -1; } freeaddrinfo(res); @@ -57,11 +138,11 @@ bool GpsdSerial::connectToGpsd() } // Switch to non-blocking so available()/read() never stall the GPS thread. - fcntl(fd, F_SETFL, O_NONBLOCK); + setNonBlocking(fd); // Ask gpsd to stream raw NMEA sentences. const char watchCmd[] = "?WATCH={\"enable\":true,\"nmea\":true}\n"; - ::write(fd, watchCmd, sizeof(watchCmd) - 1); + sendAll(fd, watchCmd, sizeof(watchCmd) - 1); _sockfd = fd; _rxBuf.clear(); @@ -80,7 +161,7 @@ void GpsdSerial::begin(unsigned long /*baud*/, uint16_t /*config*/) void GpsdSerial::end() { if (_sockfd >= 0) { - ::close(_sockfd); + closeSocket(_sockfd); _sockfd = -1; } _rxBuf.clear(); @@ -96,18 +177,18 @@ void GpsdSerial::fillBuffer() return; uint8_t tmp[256]; - ssize_t n; - while (_rxBuf.size() < RX_BUF_MAX && (n = recv(_sockfd, tmp, sizeof(tmp), MSG_DONTWAIT)) > 0) { + int n; + while (_rxBuf.size() < RX_BUF_MAX && (n = recvNonBlocking(_sockfd, tmp, sizeof(tmp))) > 0) { size_t space = RX_BUF_MAX - _rxBuf.size(); size_t toCopy = (static_cast(n) < space) ? static_cast(n) : space; for (size_t i = 0; i < toCopy; i++) _rxBuf.push_back(tmp[i]); } - if (n == 0 || (n < 0 && errno != EAGAIN && errno != EWOULDBLOCK)) { + if (n == 0 || (n < 0 && !lastErrorWasWouldBlock())) { // gpsd closed the connection or a real error occurred. LOG_WARN("gpsdSerial: disconnected, will retry"); - ::close(_sockfd); + closeSocket(_sockfd); _sockfd = -1; _rxBuf.clear(); } diff --git a/src/platform/portduino/PortduinoGlue.cpp b/src/platform/portduino/PortduinoGlue.cpp index 8da3d085b..750396769 100644 --- a/src/platform/portduino/PortduinoGlue.cpp +++ b/src/platform/portduino/PortduinoGlue.cpp @@ -21,8 +21,12 @@ #include #include #include -#include #include +#ifndef _WIN32 +// Only the PORTDUINO_LINUX_HARDWARE block below calls ioctl() (HCIGETDEVINFO, +// for the BlueZ-derived MAC address); Windows has no . +#include +#endif #ifdef PORTDUINO_LINUX_HARDWARE #include "linux/gpio/LinuxGPIOPin.h" @@ -34,6 +38,12 @@ #include #endif +#ifdef _WIN32 +// Defined in WindowsMacAddr.cpp, which keeps out of this TU: it +// pulls in RPC/OLE headers that collide with the Arduino API. +bool portduinoWindowsPrimaryMac(uint8_t *dmac); +#endif + #ifdef __APPLE__ // Used by getMacAddr()'s macOS fallback to read the en0 link-layer address. // `getifaddrs()` is the BSD-portable way; `` provides the @@ -110,6 +120,44 @@ static error_t parse_opt(int key, char *arg, struct argp_state *state) return 0; } +// A kernel SPI transfer is capped by the spidev module's `bufsiz` parameter (4096 by default). +// LovyanGFX pushes the framebuffer in large chunks, so a display bigger than that budget fails +// deep inside the driver with a bare -EMSGSIZE. Check up front so the user gets told what to fix. +static void checkSpidevBufsiz() +{ + if (portduino_config.display_spi_dev == "" || portduino_config.displayWidth == 0 || portduino_config.displayHeight == 0) { + return; + } + switch (portduino_config.displayPanel) { + case no_screen: + case x11: + case fb: + case hub75: + return; // not driven over spidev + default: + break; + } + + const long required = (long)portduino_config.displayWidth * portduino_config.displayHeight / 2 * 3; + + std::ifstream bufsizFile("/sys/module/spidev/parameters/bufsiz"); + long bufsiz = 0; + if (!bufsizFile.is_open() || !(bufsizFile >> bufsiz)) { + // spidev may be built into the kernel without exposing the parameter; nothing to check. + return; + } + + if (bufsiz < required) { + std::cerr << "SPI display " << portduino_config.displayWidth << "x" << portduino_config.displayHeight + << " needs a spidev buffer of at least " << required << " bytes, but " + << "/sys/module/spidev/parameters/bufsiz is " << bufsiz << "." << std::endl; + std::cerr << "Add 'spidev.bufsiz=" << required << "' to your kernel command line " + << "(/boot/firmware/cmdline.txt on Raspberry Pi OS) and reboot." << std::endl; + std::cerr << "Or echo that value into /etc/modprobe.d/spidev.conf and reload the spidev module" << std::endl; + exit(EXIT_FAILURE); + } +} + void portduinoCustomInit() { static struct argp_option options[] = {{"port", 'p', "PORT", 0, "The TCP port to use."}, @@ -193,6 +241,10 @@ void getMacAddr(uint8_t *dmac) } freeifaddrs(ifap); } +#elif defined(_WIN32) + // No BlueZ on Windows; the host's primary adapter MAC is the equivalent + // stable identifier. On failure dmac is untouched and the blank-MAC check fires. + portduinoWindowsPrimaryMac(dmac); #else // No platform-specific MAC source; leave dmac at its default. Caller // can override via the --hwid CLI flag or the YAML config. @@ -292,7 +344,9 @@ void portduinoSetup() std::filesystem::directory_iterator{portduino_config.config_directory}) { if (ends_with(entry.path().string(), ".yaml")) { std::cout << "Also using " << entry << " as additional config file" << std::endl; - loadConfig(entry.path().c_str()); + // .string() rather than .c_str(): path::value_type is wchar_t on + // Windows, and loadConfig() takes a const char *. + loadConfig(entry.path().string().c_str()); } } } @@ -543,6 +597,11 @@ void portduinoSetup() } } + // if we have s SPI display, check /sys/module/spidev/parameters/bufsiz + // It needs to be at least width * height / 2 * 3 + // fail with a more useful error message. + checkSpidevBufsiz(); + // if we're using a usermode driver, we need to initialize it here, to get a serial number back for mac address uint8_t dmac[6] = {0}; if (portduino_config.lora_spi_dev == "ch341") { diff --git a/src/platform/portduino/SimRadio.cpp b/src/platform/portduino/SimRadio.cpp index d0f2f1140..2786903eb 100644 --- a/src/platform/portduino/SimRadio.cpp +++ b/src/platform/portduino/SimRadio.cpp @@ -323,13 +323,19 @@ void SimRadio::startReceive(meshtastic_MeshPacket *p) return; } } - isReceiving = true; receivingPacket = packetPool.allocCopy(*p); + if (!receivingPacket) { + return; + } + isReceiving = true; uint32_t airtimeMsec = getPacketTime(p, true); notifyLater(airtimeMsec, ISR_RX, false); // Model the time it is busy receiving #else - isReceiving = true; receivingPacket = packetPool.allocCopy(*p); + if (!receivingPacket) { + return; + } + isReceiving = true; handleReceiveInterrupt(); // Simulate receiving the packet immediately startTransmitTimer(); #endif diff --git a/src/platform/portduino/USBHal.h b/src/platform/portduino/USBHal.h index 9496b2ccb..2c00ed390 100644 --- a/src/platform/portduino/USBHal.h +++ b/src/platform/portduino/USBHal.h @@ -8,6 +8,7 @@ #include #include #include +#include // gettimeofday(), previously pulled in via libusb.h #include extern uint32_t rebootAtMsec; diff --git a/src/platform/portduino/WindowsMacAddr.cpp b/src/platform/portduino/WindowsMacAddr.cpp new file mode 100644 index 000000000..8200fdc1b --- /dev/null +++ b/src/platform/portduino/WindowsMacAddr.cpp @@ -0,0 +1,54 @@ +#if defined(ARCH_PORTDUINO) && defined(_WIN32) + +// Host-MAC lookup for getMacAddr(), replacing BlueZ on Linux and en0 on macOS. +// Isolated TU: needs the header trims the env sets, and undoes them here. +#undef WIN32_LEAN_AND_MEAN +#undef NOUSER +#undef NOGDI + +// Order is load-bearing, hence the blank lines: winsock2.h must precede +// windows.h, which would otherwise pull in the colliding winsock v1 header. +#include + +#include + +#include + +#include +#include +#include + +// Fill dmac with the first up, non-loopback adapter's physical address, else +// return false. Adapter order is stable across reboots, so the identity persists. +bool portduinoWindowsPrimaryMac(uint8_t *dmac) +{ + const ULONG flags = GAA_FLAG_SKIP_ANYCAST | GAA_FLAG_SKIP_MULTICAST | GAA_FLAG_SKIP_DNS_SERVER | GAA_FLAG_SKIP_FRIENDLY_NAME; + + ULONG bufLen = 15000; // starting size recommended by the API docs + std::unique_ptr buf(new char[bufLen]); + auto *addrs = reinterpret_cast(buf.get()); + + ULONG ret = GetAdaptersAddresses(AF_UNSPEC, flags, nullptr, addrs, &bufLen); + if (ret == ERROR_BUFFER_OVERFLOW) { + // bufLen now holds the required size; retry once. + buf.reset(new char[bufLen]); + addrs = reinterpret_cast(buf.get()); + ret = GetAdaptersAddresses(AF_UNSPEC, flags, nullptr, addrs, &bufLen); + } + if (ret != NO_ERROR) + return false; + + for (auto *a = addrs; a != nullptr; a = a->Next) { + if (a->IfType == IF_TYPE_SOFTWARE_LOOPBACK) + continue; + if (a->OperStatus != IfOperStatusUp) + continue; + if (a->PhysicalAddressLength != 6) + continue; + std::memcpy(dmac, a->PhysicalAddress, 6); + return true; + } + return false; +} + +#endif // ARCH_PORTDUINO && _WIN32 diff --git a/src/platform/portduino/windows/include/libpinedio-usb.h b/src/platform/portduino/windows/include/libpinedio-usb.h new file mode 100644 index 000000000..3fe0e141a --- /dev/null +++ b/src/platform/portduino/windows/include/libpinedio-usb.h @@ -0,0 +1,75 @@ +// Windows drop-in for libch341-spi-userspace's public header, mirroring the wasm +// one in ../../wasm/include/. Same API as upstream, but backed by CH341DLL, not libusb. +#ifndef PINEDIO_USB_CH341DLL_H +#define PINEDIO_USB_CH341DLL_H +#ifdef __cplusplus +extern "C" { +#endif + +#include +#include + +enum pinedio_int_pin { + PINEDIO_PIN_D0, + PINEDIO_PIN_D1, + PINEDIO_PIN_D2, + PINEDIO_PIN_D3, + PINEDIO_PIN_D4, + PINEDIO_PIN_D5, + PINEDIO_PIN_D6, + PINEDIO_PIN_D7, + PINEDIO_PIN_ERR, + PINEDIO_PIN_PEMP, + PINEDIO_PIN_INT, + PINEDIO_INT_PIN_MAX +}; + +enum pinedio_int_mode { + PINEDIO_INT_MODE_RISING = 0x01, + PINEDIO_INT_MODE_FALLING = 0x02, +}; + +enum pinedio_option { + PINEDIO_OPTION_AUTO_CS, + PINEDIO_OPTION_SEARCH_SERIAL, + PINEDIO_OPTION_VID, + PINEDIO_OPTION_PID, + PINEDIO_OPTION_MAX +}; + +struct pinedio_inst_int { + uint8_t previous_state; + enum pinedio_int_mode mode; + void (*callback)(void); +}; + +// Ch341Hal embeds this by value and touches in_error, serial_number, +// product_string and options[], so those must keep their names. +struct pinedio_inst { + bool in_error; + struct pinedio_inst_int interrupts[PINEDIO_INT_PIN_MAX]; + uint32_t options[PINEDIO_OPTION_MAX]; + char serial_number[9]; + char product_string[97]; +}; + +typedef struct pinedio_inst pinedio_inst; + +int32_t pinedio_init(struct pinedio_inst *inst, void *driver); +int32_t pinedio_set_option(struct pinedio_inst *inst, enum pinedio_option option, uint32_t value); +int32_t pinedio_set_pin_mode(struct pinedio_inst *inst, uint32_t pin, uint32_t mode); +int32_t pinedio_digital_write(struct pinedio_inst *inst, uint32_t pin, bool active); +int32_t pinedio_set_cs(struct pinedio_inst *inst, bool active); +int32_t pinedio_write_read(struct pinedio_inst *inst, uint8_t *writearr, uint32_t writecnt, uint8_t *readarr, uint32_t readcnt); +int32_t pinedio_transceive(struct pinedio_inst *inst, uint8_t *write_buf, uint8_t *read_buf, uint32_t count); +int32_t pinedio_digital_read(struct pinedio_inst *inst, uint32_t pin); +int32_t pinedio_get_irq_state(struct pinedio_inst *inst, uint32_t pin); +int32_t pinedio_attach_interrupt(struct pinedio_inst *inst, enum pinedio_int_pin int_pin, enum pinedio_int_mode int_mode, + void (*callback)(void)); +int32_t pinedio_deattach_interrupt(struct pinedio_inst *inst, enum pinedio_int_pin int_pin); +void pinedio_deinit(struct pinedio_inst *inst); + +#ifdef __cplusplus +} +#endif +#endif // PINEDIO_USB_CH341DLL_H diff --git a/src/platform/portduino/windows/libpinedio_ch341dll.c b/src/platform/portduino/windows/libpinedio_ch341dll.c new file mode 100644 index 000000000..2f0b8e370 --- /dev/null +++ b/src/platform/portduino/windows/libpinedio_ch341dll.c @@ -0,0 +1,400 @@ +// libpinedio API over WCH's CH341DLL, replacing the libusb backend on Windows: +// libusb would need Zadig to rebind the driver, CH341DLL ships with CH341PAR. + +#if defined(ARCH_PORTDUINO) && defined(_WIN32) + +#include "libpinedio-usb.h" + +#include +#include +#include +#include +#include + +// Pins are the CH341's D0-D7: CS 0, Reset 2, SCK 3, MOSI 5, IRQ 6, MISO 7. +// CH341Set_D5_D0 reaches only the outputs; D6/D7 are read via CH341GetInput. +#define CH341_MAX_OUTPUT_PIN 5 + +// iMode bit 7: SPI bit order, 1 = MSB first. The SX1262 is MSB-first, and doing +// it in hardware avoids upstream's per-byte reverse_byte() over the whole buffer. +#define CH341_STREAM_MODE_SPI_MSB_FIRST 0x80 + +// Matches upstream's poll interval, so IRQ latency is the same as on Linux. +#define PIN_POLL_INTERVAL_MS (1000 / 30) + +typedef HANDLE(WINAPI *ch341_open_t)(ULONG); +typedef VOID(WINAPI *ch341_close_t)(ULONG); +typedef BOOL(WINAPI *ch341_set_stream_t)(ULONG, ULONG); +typedef BOOL(WINAPI *ch341_stream_spi4_t)(ULONG, ULONG, ULONG, PVOID); +typedef BOOL(WINAPI *ch341_set_d5_d0_t)(ULONG, ULONG, ULONG); +typedef BOOL(WINAPI *ch341_get_input_t)(ULONG, PULONG); +typedef PVOID(WINAPI *ch341_get_device_name_t)(ULONG); + +static struct { + HMODULE dll; + ch341_open_t open; + ch341_close_t close; + ch341_set_stream_t set_stream; + ch341_stream_spi4_t stream_spi4; + ch341_set_d5_d0_t set_d5_d0; + ch341_get_input_t get_input; + ch341_get_device_name_t get_device_name; +} ch341; + +// Single adapter, matching Ch341Hal's spiChannel of 0. Multiple sticks would +// need this and the device index threaded through pinedio_inst. +static ULONG ch341_index = 0; + +// D0-D5 direction and output state, applied together by CH341Set_D5_D0. +static ULONG pin_dir_out = 0; +static ULONG pin_state = 0; + +// Serializes DLL access between the caller and the poll thread. +static pthread_mutex_t usb_mutex = PTHREAD_MUTEX_INITIALIZER; +static pthread_t poll_thread; +static volatile bool poll_thread_exit = false; +static int int_running_cnt = 0; + +// CH341PAR installs the 64-bit library as CH341DLLA64.DLL and the 32-bit one as +// CH341DLL.DLL, so try the name matching this process first. +static const char *const ch341_dll_names[] = { +#ifdef _WIN64 + "CH341DLLA64.DLL", + "CH341DLL.DLL", +#else + "CH341DLL.DLL", + "CH341DLLA64.DLL", +#endif +}; + +// The DLL is not redistributable, so it is resolved at runtime: without it +// pinedio_init() fails and Ch341Hal throws, as it does for a missing device. +static bool load_dll(void) +{ + if (ch341.dll) + return true; + for (size_t i = 0; i < sizeof(ch341_dll_names) / sizeof(ch341_dll_names[0]); i++) { + ch341.dll = LoadLibraryA(ch341_dll_names[i]); + if (ch341.dll) + break; + } + if (!ch341.dll) { + fprintf(stderr, "CH341DLL not found; install the WCH CH341PAR driver\n"); + return false; + } + ch341.open = (ch341_open_t)(void *)GetProcAddress(ch341.dll, "CH341OpenDevice"); + ch341.close = (ch341_close_t)(void *)GetProcAddress(ch341.dll, "CH341CloseDevice"); + ch341.set_stream = (ch341_set_stream_t)(void *)GetProcAddress(ch341.dll, "CH341SetStream"); + ch341.stream_spi4 = (ch341_stream_spi4_t)(void *)GetProcAddress(ch341.dll, "CH341StreamSPI4"); + ch341.set_d5_d0 = (ch341_set_d5_d0_t)(void *)GetProcAddress(ch341.dll, "CH341Set_D5_D0"); + ch341.get_input = (ch341_get_input_t)(void *)GetProcAddress(ch341.dll, "CH341GetInput"); + ch341.get_device_name = (ch341_get_device_name_t)(void *)GetProcAddress(ch341.dll, "CH341GetDeviceName"); + + if (!ch341.open || !ch341.close || !ch341.set_stream || !ch341.stream_spi4 || !ch341.set_d5_d0 || !ch341.get_input) { + fprintf(stderr, "CH341DLL is missing expected exports\n"); + FreeLibrary(ch341.dll); + ch341.dll = NULL; + return false; + } + return true; +} + +static int32_t apply_pins(void) +{ + if (!ch341.set_d5_d0(ch341_index, pin_dir_out, pin_state)) + return -1; + return 0; +} + +int32_t pinedio_set_option(struct pinedio_inst *inst, enum pinedio_option option, uint32_t value) +{ + if (option < PINEDIO_OPTION_MAX) + inst->options[option] = value; + return 0; +} + +int32_t pinedio_init(struct pinedio_inst *inst, void *driver) +{ + (void)driver; + inst->in_error = false; + for (int i = 0; i < PINEDIO_INT_PIN_MAX; i++) + inst->interrupts[i].callback = NULL; + + if (!load_dll()) + return -1; + + if (ch341.open(ch341_index) == INVALID_HANDLE_VALUE) { + fprintf(stderr, "CH341OpenDevice(%lu) failed; is the adapter plugged in?\n", ch341_index); + return -2; + } + + // Default speed (bits 0-1 = 01) plus MSB-first. + if (!ch341.set_stream(ch341_index, 0x01 | CH341_STREAM_MODE_SPI_MSB_FIRST)) { + fprintf(stderr, "CH341SetStream failed\n"); + ch341.close(ch341_index); + return -3; + } + + pin_dir_out = 0; + pin_state = 0; + + // CH341DLL exposes no USB serial string. Leaving it empty is safe: on Windows + // getMacAddr() uses the host adapter, and the device name stands in for the product. + inst->serial_number[0] = '\0'; + inst->product_string[0] = '\0'; + if (ch341.get_device_name) { + const char *name = (const char *)ch341.get_device_name(ch341_index); + if (name) { + strncpy(inst->product_string, name, sizeof(inst->product_string) - 1); + inst->product_string[sizeof(inst->product_string) - 1] = '\0'; + } + } + return 0; +} + +int32_t pinedio_set_pin_mode(struct pinedio_inst *inst, uint32_t pin, uint32_t mode) +{ + (void)inst; + if (pin > CH341_MAX_OUTPUT_PIN) + return 0; // D6/D7 are input-only; nothing to configure + + // Under the lock: the poll thread's callback can drive GPIO concurrently. + pthread_mutex_lock(&usb_mutex); + if (mode) + pin_dir_out |= (1u << pin); + else + pin_dir_out &= ~(1u << pin); + pthread_mutex_unlock(&usb_mutex); + // Upstream defers the device write to the next digital_write; do the same so + // the direction and level land together. + return 0; +} + +int32_t pinedio_digital_write(struct pinedio_inst *inst, uint32_t pin, bool active) +{ + if (pin > CH341_MAX_OUTPUT_PIN) + return -1; + + // Mutate and apply as one critical section, or a concurrent write loses its + // update when apply_pins() reads a half-updated pin_state. + pthread_mutex_lock(&usb_mutex); + if (active) + pin_state |= (1u << pin); + else + pin_state &= ~(1u << pin); + int32_t ret = apply_pins(); + pthread_mutex_unlock(&usb_mutex); + if (ret < 0) + inst->in_error = true; + return ret; +} + +int32_t pinedio_set_cs(struct pinedio_inst *inst, bool active) +{ + return pinedio_digital_write(inst, 0, active); // D0 is CS +} + +static int32_t read_input(uint32_t *out) +{ + ULONG status = 0; + if (!ch341.get_input(ch341_index, &status)) + return -1; + *out = (uint32_t)status; + return 0; +} + +int32_t pinedio_digital_read(struct pinedio_inst *inst, uint32_t pin) +{ + uint32_t status = 0; + pthread_mutex_lock(&usb_mutex); + int32_t ret = read_input(&status); + pthread_mutex_unlock(&usb_mutex); + if (ret < 0) { + inst->in_error = true; + return ret; + } + return (status & (1u << pin)) ? 1 : 0; // bits 7-0 are D7-D0 +} + +int32_t pinedio_get_irq_state(struct pinedio_inst *inst, uint32_t pin) +{ + return pinedio_digital_read(inst, pin); +} + +int32_t pinedio_transceive(struct pinedio_inst *inst, uint8_t *write_buf, uint8_t *read_buf, uint32_t count) +{ + if (count == 0) + return 0; + + // CH341StreamSPI4 is full duplex over one in/out buffer. + uint8_t stack_buf[64]; + uint8_t *buf = count <= sizeof(stack_buf) ? stack_buf : (uint8_t *)malloc(count); + if (!buf) + return -1; + memcpy(buf, write_buf, count); + + // Bit 7 clear: leave chip select alone. Ch341Hal sets PINEDIO_OPTION_AUTO_CS + // to 0 and lets RadioLib drive NSS through digitalWrite. + ULONG cs = 0; + if (inst->options[PINEDIO_OPTION_AUTO_CS]) + cs = 0x80; // enable, D0 active low + + pthread_mutex_lock(&usb_mutex); + BOOL ok = ch341.stream_spi4(ch341_index, cs, count, buf); + pthread_mutex_unlock(&usb_mutex); + + if (ok && read_buf) + memcpy(read_buf, buf, count); + if (buf != stack_buf) + free(buf); + + if (!ok) { + inst->in_error = true; + return -1; + } + return 0; +} + +int32_t pinedio_write_read(struct pinedio_inst *inst, uint8_t *writearr, uint32_t writecnt, uint8_t *readarr, uint32_t readcnt) +{ + uint32_t total = writecnt + readcnt; + uint8_t stack_buf[64]; + uint8_t *buf = total <= sizeof(stack_buf) ? stack_buf : (uint8_t *)malloc(total); + if (!buf) + return -1; + memcpy(buf, writearr, writecnt); + memset(buf + writecnt, 0, readcnt); + + int32_t ret = pinedio_transceive(inst, buf, buf, total); + if (ret == 0) + memcpy(readarr, buf + writecnt, readcnt); + if (buf != stack_buf) + free(buf); + return ret; +} + +// CH341SetIntRoutine only fires on the INT# pin, but the adapter wires DIO1 to +// D6, so poll D6 instead, at upstream's rate and edge semantics. +static void *pin_poll_thread_fn(void *arg) +{ + struct pinedio_inst *inst = (struct pinedio_inst *)arg; + bool should_exit = false; + + while (!should_exit) { + uint32_t input = 0; + pthread_mutex_lock(&usb_mutex); + int32_t ret = read_input(&input); + pthread_mutex_unlock(&usb_mutex); + if (ret < 0) { + inst->in_error = true; + fprintf(stderr, "CH341 poll: failed to read input\n"); + break; + } + inst->in_error = false; + + pthread_mutex_lock(&usb_mutex); + for (uint8_t int_pin = 0; int_pin < PINEDIO_INT_PIN_MAX; int_pin++) { + struct pinedio_inst_int *inst_int = &inst->interrupts[int_pin]; + // Copy under the lock: pinedio_deattach_interrupt() could NULL it + // once we drop the lock to make the call. + void (*cb)(void) = inst_int->callback; + if (cb == NULL) + continue; + uint8_t state = (input & (1u << int_pin)) != 0; + if (inst_int->previous_state != 255 && inst_int->previous_state != state) { + enum pinedio_int_mode mode = + (!inst_int->previous_state && state) ? PINEDIO_INT_MODE_RISING : PINEDIO_INT_MODE_FALLING; + if (inst_int->mode & mode) { + // Callback re-enters this library, so drop the lock first. + pthread_mutex_unlock(&usb_mutex); + cb(); + pthread_mutex_lock(&usb_mutex); + } + } + inst_int->previous_state = state; + } + should_exit = poll_thread_exit; + pthread_mutex_unlock(&usb_mutex); + Sleep(PIN_POLL_INTERVAL_MS); + } + return NULL; +} + +int32_t pinedio_attach_interrupt(struct pinedio_inst *inst, enum pinedio_int_pin int_pin, enum pinedio_int_mode int_mode, + void (*callback)(void)) +{ + if (int_pin >= PINEDIO_INT_PIN_MAX) + return -1; + + int32_t res = 0; + pthread_mutex_lock(&usb_mutex); + bool was_attached = inst->interrupts[int_pin].callback != NULL; + inst->interrupts[int_pin].previous_state = 255; + inst->interrupts[int_pin].mode = int_mode; + inst->interrupts[int_pin].callback = callback; + + // Only a new attachment changes the refcount. Re-attaching an armed pin + // (RadioLib does this) must not reach 0 and spawn a second poll thread. + if (!was_attached) { + if (int_running_cnt == 0) { + poll_thread_exit = false; + res = pthread_create(&poll_thread, NULL, pin_poll_thread_fn, inst); + if (res != 0) { + fprintf(stderr, "CH341: failed to start poll thread: %d\n", res); + inst->interrupts[int_pin].callback = NULL; + pthread_mutex_unlock(&usb_mutex); + return res; + } + } + int_running_cnt++; + } + pthread_mutex_unlock(&usb_mutex); + return res; +} + +int32_t pinedio_deattach_interrupt(struct pinedio_inst *inst, enum pinedio_int_pin int_pin) +{ + if (int_pin >= PINEDIO_INT_PIN_MAX) + return -1; + + pthread_t thread_to_join; + pthread_mutex_lock(&usb_mutex); + bool was_attached = inst->interrupts[int_pin].callback != NULL; + inst->interrupts[int_pin].callback = NULL; + if (was_attached) + int_running_cnt--; + bool stop = was_attached && int_running_cnt == 0; + if (stop) { + poll_thread_exit = true; + // Copy the handle: a concurrent attach could overwrite poll_thread once + // the lock is dropped, and we would join the wrong thread. + thread_to_join = poll_thread; + } + pthread_mutex_unlock(&usb_mutex); + + // Joining under the lock would deadlock against the poll thread taking it. + if (stop && !pthread_equal(thread_to_join, pthread_self())) + pthread_join(thread_to_join, NULL); + return 0; +} + +void pinedio_deinit(struct pinedio_inst *inst) +{ + pthread_t thread_to_join; + pthread_mutex_lock(&usb_mutex); + bool stop = int_running_cnt > 0; + poll_thread_exit = true; + int_running_cnt = 0; + if (stop) + thread_to_join = poll_thread; // copy before dropping the lock, as above + pthread_mutex_unlock(&usb_mutex); + + if (stop && !pthread_equal(thread_to_join, pthread_self())) + pthread_join(thread_to_join, NULL); + + if (ch341.dll) + ch341.close(ch341_index); + inst->in_error = false; +} + +#endif // ARCH_PORTDUINO && _WIN32 diff --git a/src/power/PowerHAL.cpp b/src/power/PowerHAL.cpp index 0a8d5f10b..bc6af4143 100644 --- a/src/power/PowerHAL.cpp +++ b/src/power/PowerHAL.cpp @@ -1,19 +1,27 @@ #include "PowerHAL.h" +// PE/COFF has no ELF-style weak definitions, so a weak default is an undefined +// reference on Windows. Only nrf52 and nrf54l15 override these; define them strongly. +#ifdef _WIN32 +#define POWERHAL_WEAK_DEFAULT __attribute__((noinline)) +#else +#define POWERHAL_WEAK_DEFAULT __attribute__((weak, noinline)) +#endif + void powerHAL_init() { return powerHAL_platformInit(); } -__attribute__((weak, noinline)) void powerHAL_platformInit() {} +POWERHAL_WEAK_DEFAULT void powerHAL_platformInit() {} -__attribute__((weak, noinline)) bool powerHAL_isPowerLevelSafe() +POWERHAL_WEAK_DEFAULT bool powerHAL_isPowerLevelSafe() { return true; } -__attribute__((weak, noinline)) bool powerHAL_isVBUSConnected() +POWERHAL_WEAK_DEFAULT bool powerHAL_isVBUSConnected() { return false; } diff --git a/src/security/EncryptedStorage.cpp b/src/security/EncryptedStorage.cpp index 206bb4ac2..d34906eaa 100644 --- a/src/security/EncryptedStorage.cpp +++ b/src/security/EncryptedStorage.cpp @@ -1078,16 +1078,6 @@ bool isSessionExpired() return (millis() - s_sessionStartedMs) > s_sessionMaxMs; } -uint32_t getSessionRemainingSeconds() -{ - if (s_sessionMaxMs == 0) - return 0; - uint32_t elapsedMs = millis() - s_sessionStartedMs; - if (elapsedMs >= s_sessionMaxMs) - return 0; - return (s_sessionMaxMs - elapsedMs) / 1000UL; -} - uint8_t consumeSessionBoot() { if (s_bootsRemaining == 0) { diff --git a/src/security/EncryptedStorage.h b/src/security/EncryptedStorage.h index 06cba0061..3568867d0 100644 --- a/src/security/EncryptedStorage.h +++ b/src/security/EncryptedStorage.h @@ -243,10 +243,6 @@ void setSession(uint32_t maxSeconds); /// from the main loop on a low-frequency tick. bool isSessionExpired(); -/// Seconds remaining in the current session. 0 if no timer is set, or if -/// the timer has expired (use isSessionExpired() to distinguish). -uint32_t getSessionRemainingSeconds(); - /// Consume one boot from the on-flash token (the rollback ledger) and /// re-arm the session timer in place - no reboot. Called from the main /// loop when a session expires AND there is still budget. Decrements diff --git a/src/xmodem.cpp b/src/xmodem.cpp index ce7ad8020..58339a369 100644 --- a/src/xmodem.cpp +++ b/src/xmodem.cpp @@ -62,10 +62,13 @@ bool XModemAdapter::isValidFilename(const char *name) { if (!name || name[0] == '\0') return false; + const bool driveLetter = (name[0] >= 'A' && name[0] <= 'Z') || (name[0] >= 'a' && name[0] <= 'z'); + if (driveLetter && name[1] == ':') + return false; // Reject any ".." path component. Absolute paths and subdirectories are fine; they stay within // the filesystem root, so only traversal out of it needs blocking. for (const char *seg = name; *seg;) { - const char *slash = strchr(seg, '/'); + const char *slash = strpbrk(seg, "/\\"); const size_t len = slash ? (size_t)(slash - seg) : strlen(seg); if (len == 2 && seg[0] == '.' && seg[1] == '.') return false; diff --git a/test/README.md b/test/README.md index 58700abe7..68b271f1e 100644 --- a/test/README.md +++ b/test/README.md @@ -404,6 +404,8 @@ A well-structured test suite follows this pattern: | `test_position_precision` | Position precision helpers | | `test_radio` | Radio interface | | `test_serial` | Serial communication | +| `test_module_config` | AdminModule module config | +| `test_tak_config` | TAK (ATAK) team/role values | | `test_traffic_management` | Traffic management | | `test_transmit_history` | Retransmission tracking | | `test_type_conversions` | NodeDB v25 type conversions | diff --git a/test/native-suite-count b/test/native-suite-count index 8f92bfdd4..87523dd7a 100644 --- a/test/native-suite-count +++ b/test/native-suite-count @@ -1 +1 @@ -35 +41 diff --git a/test/support/AdminModuleTestShim.h b/test/support/AdminModuleTestShim.h index c189bee84..1b06457f1 100644 --- a/test/support/AdminModuleTestShim.h +++ b/test/support/AdminModuleTestShim.h @@ -9,9 +9,11 @@ class AdminModuleTestShim : public AdminModule public: using AdminModule::checkPassKey; // session-key gate seam (see test_admin_session_repro) using AdminModule::handleGetConfig; + using AdminModule::handleGetModuleConfig; using AdminModule::handleReceivedProtobuf; using AdminModule::handleSetConfig; using AdminModule::handleSetModuleConfig; + using AdminModule::handleSetOwner; using AdminModule::responseIsSolicited; // request/response pairing gate using AdminModule::setPassKey; @@ -20,6 +22,7 @@ class AdminModuleTestShim : public AdminModule // With an "open edit transaction" saveChanges() is a pure no-op: no reloadConfig/saveToDisk/reboot. void deferSaves() { hasOpenEditTransaction = true; } + int savedSegments() const { return lastSaveWhatForTest; } // Setters may allocate an error reply from packetPool; drain it each iteration or the pool leaks. void drainReply() diff --git a/test/support/MockMeshService.h b/test/support/MockMeshService.h index 6bfeed077..1f863ef23 100644 --- a/test/support/MockMeshService.h +++ b/test/support/MockMeshService.h @@ -6,5 +6,11 @@ class MockMeshService : public MeshService { public: - void sendClientNotification(meshtastic_ClientNotification *n) override { releaseClientNotificationToPool(n); } + void sendClientNotification(meshtastic_ClientNotification *n) override + { + notificationCount++; + releaseClientNotificationToPool(n); + } + + uint32_t notificationCount = 0; }; diff --git a/test/test_admin_radio/test_main.cpp b/test/test_admin_radio/test_main.cpp index 3ce99fcb6..966cc6859 100644 --- a/test/test_admin_radio/test_main.cpp +++ b/test/test_admin_radio/test_main.cpp @@ -11,7 +11,9 @@ * 6. Channel spacing calculation (placeholder for future protobuf changes) */ +#include "Channels.h" #include "DisplayFormatters.h" +#include "FSCommon.h" #include "MeshRadio.h" #include "MeshService.h" #include "NodeDB.h" @@ -19,6 +21,9 @@ #include "TestUtil.h" #include "mesh/Channels.h" #include "modules/AdminModule.h" +#include "modules/NodeInfoModule.h" +#include +#include #include #include #include @@ -949,6 +954,138 @@ static void test_channelSpacingCalculation_placeholder() // AdminModuleTestShim comes from test/support - the friend seam AdminModule.h declares. static AdminModuleTestShim *testAdmin; +static bool adminRadioGlobalsActive; +static NodeDB *savedNodeDB; +static NodeDB *replacementNodeDB; +static NodeInfoModule *savedNodeInfoModule; +static meshtastic_DeviceState savedDeviceState; +static meshtastic_User savedOwner; +static meshtastic_LocalConfig savedConfig; +static meshtastic_ChannelFile savedChannelFile; + +static void replaceAdminRadioGlobals() +{ + savedNodeDB = nodeDB; + savedNodeInfoModule = nodeInfoModule; + savedDeviceState = devicestate; + savedOwner = owner; + savedConfig = config; + savedChannelFile = channelFile; + replacementNodeDB = new NodeDB(); + nodeDB = replacementNodeDB; + adminRadioGlobalsActive = true; +} + +static void restoreAdminRadioGlobals() +{ + if (!adminRadioGlobalsActive) + return; + nodeInfoModule = savedNodeInfoModule; + nodeDB = savedNodeDB; + delete replacementNodeDB; + replacementNodeDB = nullptr; + devicestate = savedDeviceState; + owner = savedOwner; + config = savedConfig; + channelFile = savedChannelFile; + initRegion(); + adminRadioGlobalsActive = false; +} + +static void installEncryptedAndAdminChannels() +{ + channels.initDefaults(); + meshtastic_Channel admin = meshtastic_Channel_init_zero; + admin.index = 1; + admin.role = meshtastic_Channel_Role_SECONDARY; + admin.has_settings = true; + strncpy(admin.settings.name, Channels::adminChannel, sizeof(admin.settings.name)); + admin.settings.psk.size = 16; + memset(admin.settings.psk.bytes, 0xA5, admin.settings.psk.size); + channels.setChannel(admin); + + meshtastic_Channel secondary = meshtastic_Channel_init_zero; + secondary.index = 2; + secondary.role = meshtastic_Channel_Role_SECONDARY; + secondary.has_settings = true; + strncpy(secondary.settings.name, "private", sizeof(secondary.settings.name)); + secondary.settings.psk.size = 32; + memset(secondary.settings.psk.bytes, 0x5A, secondary.settings.psk.size); + channels.setChannel(secondary); +} + +static void assertLicensedChannelsSanitized() +{ + TEST_ASSERT_EQUAL(0, channels.getByIndex(0).settings.psk.size); + TEST_ASSERT_EQUAL(meshtastic_Channel_Role_DISABLED, channels.getByIndex(1).role); + TEST_ASSERT_EQUAL(0, channels.getByIndex(1).settings.psk.size); + TEST_ASSERT_EQUAL(0, channels.getByIndex(2).settings.psk.size); +} + +static void test_handleSetOwner_persistsLicensedChannelSanitation() +{ + replaceAdminRadioGlobals(); + owner = meshtastic_User_init_zero; + config.lora.region = meshtastic_Config_LoRaConfig_RegionCode_UNSET; + installEncryptedAndAdminChannels(); + + meshtastic_User licensed = meshtastic_User_init_zero; + licensed.is_licensed = true; + testAdmin->deferSaves(); + nodeInfoModule = reinterpret_cast(1); // reloadOwner(false) only checks presence + testAdmin->handleSetOwner(licensed); + + TEST_ASSERT_TRUE(testAdmin->savedSegments() & SEGMENT_CHANNELS); + assertLicensedChannelsSanitized(); + + uint8_t encoded[meshtastic_ChannelFile_size]; + const size_t encodedSize = pb_encode_to_bytes(encoded, sizeof(encoded), &meshtastic_ChannelFile_msg, &channelFile); + TEST_ASSERT_GREATER_THAN(0, encodedSize); + meshtastic_ChannelFile reloaded = meshtastic_ChannelFile_init_zero; + TEST_ASSERT_TRUE(pb_decode_from_bytes(encoded, encodedSize, &meshtastic_ChannelFile_msg, &reloaded)); + channelFile = reloaded; + assertLicensedChannelsSanitized(); + TEST_ASSERT_FALSE_MESSAGE(channels.ensureLicensedOperation(), "sanitized reload must not trigger another persistence write"); +} + +static void test_bootDefense_sanitizesStaleLicensedChannelsOnce() +{ + owner = meshtastic_User_init_zero; + owner.is_licensed = true; + installEncryptedAndAdminChannels(); + + TEST_ASSERT_TRUE(channels.ensureLicensedOperation()); + assertLicensedChannelsSanitized(); + TEST_ASSERT_FALSE_MESSAGE(channels.ensureLicensedOperation(), "boot sanitation must be idempotent"); +} + +static void test_restorePreferences_sanitizesLicensedBackupBeforeReturn() +{ + NodeDB *savedNodeDB = nodeDB; + nodeDB = new NodeDB(); + const meshtastic_DeviceState savedDeviceState = devicestate; + const meshtastic_ChannelFile savedChannelFile = channelFile; + + owner = meshtastic_User_init_zero; + owner.is_licensed = true; + installEncryptedAndAdminChannels(); + TEST_ASSERT_TRUE(nodeDB->backupPreferences(meshtastic_AdminMessage_BackupLocation_FLASH)); + + owner.is_licensed = false; + channels.initDefaults(); + TEST_ASSERT_TRUE( + nodeDB->restorePreferences(meshtastic_AdminMessage_BackupLocation_FLASH, SEGMENT_DEVICESTATE | SEGMENT_CHANNELS)); + TEST_ASSERT_TRUE(owner.is_licensed); + assertLicensedChannelsSanitized(); + TEST_ASSERT_FALSE_MESSAGE(channels.ensureLicensedOperation(), "restored licensed channels must remain sanitized"); + + devicestate = savedDeviceState; + channelFile = savedChannelFile; + nodeDB->saveToDisk(SEGMENT_DEVICESTATE | SEGMENT_CHANNELS); + FSCom.remove(backupFileName); + delete nodeDB; + nodeDB = savedNodeDB; +} static meshtastic_Config makeLoraSetConfig(meshtastic_Config_LoRaConfig_RegionCode region, bool usePreset, meshtastic_Config_LoRaConfig_ModemPreset preset) @@ -961,6 +1098,27 @@ static meshtastic_Config makeLoraSetConfig(meshtastic_Config_LoRaConfig_RegionCo return c; } +static void test_handleSetConfig_persistsLicensedFirstRegionIdentity() +{ + replaceAdminRadioGlobals(); + owner = meshtastic_User_init_zero; + owner.is_licensed = true; + config.security = meshtastic_Config_SecurityConfig_init_zero; + config.lora = meshtastic_Config_LoRaConfig_init_zero; + config.lora.region = meshtastic_Config_LoRaConfig_RegionCode_UNSET; + initRegion(); + + testAdmin->deferSaves(); + const meshtastic_Config c = + makeLoraSetConfig(meshtastic_Config_LoRaConfig_RegionCode_US, true, meshtastic_Config_LoRaConfig_ModemPreset_LONG_FAST); + testAdmin->handleSetConfig(c, false); + + const int expectedSegments = SEGMENT_CONFIG | SEGMENT_MODULECONFIG | SEGMENT_DEVICESTATE | SEGMENT_NODEDATABASE; + TEST_ASSERT_EQUAL_INT(expectedSegments, testAdmin->savedSegments()); + TEST_ASSERT_EQUAL(32, config.security.private_key.size); + TEST_ASSERT_EQUAL(32, owner.public_key.size); +} + static void test_handleSetConfig_fromOthers_invalidPresetRejected() { // Set up a known-good baseline in the global config @@ -1197,6 +1355,80 @@ static void test_handleSetConfig_security_acceptsSuppliedKeypair() TEST_ASSERT_EQUAL_MEMORY(expectedPub, config.security.public_key.bytes, 32); } +// Issue #11073: "regenerate keys" sends a blank SecurityConfig holding only the new private key. Replacing +// the whole struct with it wiped the admin keys, locking the owner out of remote admin. +static void test_handleSetConfig_security_rotationPreservesAdminKeys() +{ + config.security = meshtastic_Config_SecurityConfig_init_zero; + config.security.private_key.size = 32; + memset(config.security.private_key.bytes, 0x11, 32); + config.security.public_key.size = 32; + memset(config.security.public_key.bytes, 0x22, 32); + config.security.admin_key_count = 2; + config.security.admin_key[0].size = 32; + memset(config.security.admin_key[0].bytes, 0xAA, 32); + config.security.admin_key[1].size = 32; + memset(config.security.admin_key[1].bytes, 0xBB, 32); + config.security.is_managed = true; + config.security.serial_enabled = true; + config.security.packet_signature_policy = + meshtastic_Config_SecurityConfig_PacketSignaturePolicy_PACKET_SIGNATURE_POLICY_STRICT; + + // Exactly what the regenerate dialog emits. + meshtastic_Config c = meshtastic_Config_init_zero; + c.which_payload_variant = meshtastic_Config_security_tag; + c.payload_variant.security.private_key.size = 32; + memset(c.payload_variant.security.private_key.bytes, 0x33, 32); + + testAdmin->deferSaves(); + testAdmin->handleSetConfig(c, false); + + uint8_t expectedPriv[32]; + memset(expectedPriv, 0x33, 32); + TEST_ASSERT_EQUAL_UINT(32, config.security.private_key.size); + TEST_ASSERT_EQUAL_MEMORY(expectedPriv, config.security.private_key.bytes, 32); + + uint8_t expectedAdmin0[32], expectedAdmin1[32]; + memset(expectedAdmin0, 0xAA, 32); + memset(expectedAdmin1, 0xBB, 32); + TEST_ASSERT_EQUAL_UINT(2, config.security.admin_key_count); + TEST_ASSERT_EQUAL_UINT(32, config.security.admin_key[0].size); + TEST_ASSERT_EQUAL_MEMORY(expectedAdmin0, config.security.admin_key[0].bytes, 32); + TEST_ASSERT_EQUAL_UINT(32, config.security.admin_key[1].size); + TEST_ASSERT_EQUAL_MEMORY(expectedAdmin1, config.security.admin_key[1].bytes, 32); + TEST_ASSERT_TRUE(config.security.is_managed); + TEST_ASSERT_TRUE(config.security.serial_enabled); + TEST_ASSERT_EQUAL(meshtastic_Config_SecurityConfig_PacketSignaturePolicy_PACKET_SIGNATURE_POLICY_STRICT, + config.security.packet_signature_policy); +} + +// The escape hatch: a SET that leaves the private key alone still clears admin keys. +static void test_handleSetConfig_security_clearsAdminKeysWhenKeypairUnchanged() +{ + config.security = meshtastic_Config_SecurityConfig_init_zero; + config.security.private_key.size = 32; + memset(config.security.private_key.bytes, 0x11, 32); + config.security.public_key.size = 32; + memset(config.security.public_key.bytes, 0x22, 32); + config.security.admin_key_count = 1; + config.security.admin_key[0].size = 32; + memset(config.security.admin_key[0].bytes, 0xAA, 32); + + // Same private key we already hold, empty admin key list. + meshtastic_Config c = meshtastic_Config_init_zero; + c.which_payload_variant = meshtastic_Config_security_tag; + c.payload_variant.security.private_key.size = 32; + memset(c.payload_variant.security.private_key.bytes, 0x11, 32); + c.payload_variant.security.public_key.size = 32; + memset(c.payload_variant.security.public_key.bytes, 0x22, 32); + + testAdmin->deferSaves(); + testAdmin->handleSetConfig(c, false); + + TEST_ASSERT_EQUAL_UINT(0, config.security.admin_key_count); + TEST_ASSERT_EQUAL_UINT(0, config.security.admin_key[0].size); +} + static void test_regionInfo_supportsPreset() { const RegionInfo *eu868 = getRegion(meshtastic_Config_LoRaConfig_RegionCode_EU_868); @@ -1221,6 +1453,16 @@ static void test_checkConfigRegion_quietCheckReportsReason() TEST_ASSERT_TRUE_MESSAGE(strlen(err) > 0, "Expected a failure reason in errBuf"); } +static void test_checkConfigRegion_allowsProspectiveLicensedOwner() +{ + meshtastic_Config_LoRaConfig cfg = meshtastic_Config_LoRaConfig_init_zero; + cfg.region = meshtastic_Config_LoRaConfig_RegionCode_ITU1_2M; + devicestate.owner.is_licensed = false; + + TEST_ASSERT_FALSE(RadioInterface::checkConfigRegion(cfg)); + TEST_ASSERT_TRUE(RadioInterface::checkConfigRegion(cfg, nullptr, 0, true)); +} + static void test_handleSetConfig_fromOthers_siblingLockedPresetSwapsRegion() { // Baseline: EU_866 (LITE profile) @@ -1441,6 +1683,7 @@ void setUp(void) } void tearDown(void) { + restoreAdminRadioGlobals(); service = nullptr; delete mockMeshService; mockMeshService = nullptr; @@ -1458,6 +1701,10 @@ void setup() UNITY_BEGIN(); // getRegion() + RUN_TEST(test_handleSetOwner_persistsLicensedChannelSanitation); + RUN_TEST(test_handleSetConfig_persistsLicensedFirstRegionIdentity); + RUN_TEST(test_bootDefense_sanitizesStaleLicensedChannelsOnce); + RUN_TEST(test_restorePreferences_sanitizesLicensedBackupBeforeReturn); RUN_TEST(test_getRegion_returnsCorrectRegion_US); RUN_TEST(test_getRegion_returnsCorrectRegion_EU868); RUN_TEST(test_getRegion_returnsCorrectRegion_LORA24); @@ -1540,8 +1787,11 @@ void setup() RUN_TEST(test_handleSetConfig_fromLocal_customBandwidthNonZeroPreserved); RUN_TEST(test_handleSetConfig_security_preservesKeypairWhenPrivateOmitted); RUN_TEST(test_handleSetConfig_security_acceptsSuppliedKeypair); + RUN_TEST(test_handleSetConfig_security_rotationPreservesAdminKeys); + RUN_TEST(test_handleSetConfig_security_clearsAdminKeysWhenKeypairUnchanged); RUN_TEST(test_regionInfo_supportsPreset); RUN_TEST(test_checkConfigRegion_quietCheckReportsReason); + RUN_TEST(test_checkConfigRegion_allowsProspectiveLicensedOwner); RUN_TEST(test_handleSetConfig_fromOthers_siblingLockedPresetSwapsRegion); RUN_TEST(test_handleSetConfig_fromOthers_lockedPresetFromNonTrioRegionRejected); diff --git a/test/test_admin_session_repro/test_main.cpp b/test/test_admin_session_repro/test_main.cpp index 2a4cdda8d..c93c1fd94 100644 --- a/test/test_admin_session_repro/test_main.cpp +++ b/test/test_admin_session_repro/test_main.cpp @@ -21,6 +21,10 @@ #include "support/MockMeshService.h" #include +#ifdef ARCH_PORTDUINO +#include "platform/portduino/PortduinoGlue.h" +#endif + static constexpr NodeNum LOCAL_NODE = 0x0A0A0A0A; static constexpr NodeNum ADMIN_NODE = 0x0B0B0B0B; // authorized admin, sends remote admin to us static constexpr NodeNum QUERIED_NODE = 0x0C0C0C0C; // a remote we send admin requests to @@ -29,8 +33,43 @@ static const uint8_t ADMIN_KEY[32] = {0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, 0xcc, 0xdd, 0xee, 0xff, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e, 0x0f, 0x10, 0x20}; +// MeshService assigns every outgoing packet an id before noteOutgoingAdminRequest sees it, and +// setReplyTo echoes it back as decoded.request_id, so the pairing is keyed on it. +static constexpr uint32_t REQUEST_ID = 0x5EED0001; +static const uint8_t QUERIED_KEY[32] = {0xC1, 0xC2, 0xC3, 0xC4, 0xC5, 0xC6, 0xC7, 0xC8, 0xC9, 0xCa, 0xCb, + 0xCc, 0xCd, 0xCe, 0xCf, 0xD0, 0xD1, 0xD2, 0xD3, 0xD4, 0xD5, 0xD6, + 0xD7, 0xD8, 0xD9, 0xDa, 0xDb, 0xDc, 0xDd, 0xDe, 0xDf, 0xE0}; + +// NodeDB with injectable nodes, so a destination can have a stored public key to pin. +class MockNodeDB : public NodeDB +{ + public: + void clearTestNodes() + { + testNodes.clear(); + meshNodes = &testNodes; + numMeshNodes = 0; + } + + void addNodeWithKey(NodeNum num, const uint8_t *key) + { + meshtastic_NodeInfoLite n = meshtastic_NodeInfoLite_init_zero; + n.num = num; + if (key) { + n.public_key.size = 32; + memcpy(n.public_key.bytes, key, 32); + } + testNodes.push_back(n); + meshNodes = &testNodes; + numMeshNodes = testNodes.size(); + } + + std::vector testNodes; +}; + static MockMeshService *mockService = nullptr; static AdminModuleTestShim *admin = nullptr; +static MockNodeDB *mockNodeDB = nullptr; // A remote, PKC-authorized set_owner. `session` (if non-empty) is the session_passkey the client presents. static meshtastic_MeshPacket makeRemoteSetOwner(const char *newLongName, const uint8_t *session, size_t sessionLen, @@ -71,6 +110,7 @@ static meshtastic_MeshPacket makeModuleConfigResponse(NodeNum from, meshtastic_A mp.to = LOCAL_NODE; mp.channel = 0; mp.which_payload_variant = meshtastic_MeshPacket_decoded_tag; + mp.decoded.request_id = REQUEST_ID; // setReplyTo echoes the request's packet id return mp; } @@ -93,6 +133,7 @@ static meshtastic_MeshPacket makeOutgoingRequest(NodeNum to, const meshtastic_Ad p.to = to; p.which_payload_variant = meshtastic_MeshPacket_decoded_tag; p.decoded.portnum = meshtastic_PortNum_ADMIN_APP; + p.id = REQUEST_ID; p.decoded.payload.size = pb_encode_to_bytes(p.decoded.payload.bytes, sizeof(p.decoded.payload.bytes), &meshtastic_AdminMessage_msg, &req); return p; @@ -114,11 +155,24 @@ void setUp(void) admin = new AdminModuleTestShim(); admin->deferSaves(); // no disk/reboot side effects when a setter is accepted - if (!nodeDB) - nodeDB = new NodeDB(); + if (!mockNodeDB) + mockNodeDB = new MockNodeDB(); + mockNodeDB->clearTestNodes(); + nodeDB = mockNodeDB; myNodeInfo.my_node_num = LOCAL_NODE; +#ifdef ARCH_PORTDUINO + // The native test harness boots Portduino in simulated mode, and wouldEncryptWithPKC() + // hard-disables PKC whenever force_simradio is set. Left true, no outgoing admin request is + // ever key-pinned, so the pinning tests below cannot exercise what they are asserting. + // Model a real (non-sim) device instead. + portduino_config.force_simradio = false; +#endif + config = meshtastic_LocalConfig_init_zero; + // A real device always holds a private key; without one perhapsEncode never picks PKC. + config.security.private_key.size = 32; + memset(config.security.private_key.bytes, 0xA5, 32); // Authorize ADMIN_NODE's key as an admin key so the PKC path accepts it and we reach the session gate. config.security.admin_key[0].size = 32; memcpy(config.security.admin_key[0].bytes, ADMIN_KEY, 32); @@ -250,6 +304,132 @@ void test_local_security_config_keeps_private_key(void) admin->drainReply(); } +// A local client reads the effective policy after a set. Builds without packet-signature support coerce it to Compatible. +void test_local_security_config_applies_packet_signature_policy(void) +{ + meshtastic_Config set = meshtastic_Config_init_zero; + set.which_payload_variant = meshtastic_Config_security_tag; + set.payload_variant.security = config.security; + set.payload_variant.security.packet_signature_policy = + meshtastic_Config_SecurityConfig_PacketSignaturePolicy_PACKET_SIGNATURE_POLICY_STRICT; + admin->handleSetConfig(set, false); + + meshtastic_MeshPacket req = makeGetConfigRequest(0); + admin->handleGetConfig(req, meshtastic_AdminMessage_ConfigType_SECURITY_CONFIG); + + meshtastic_Config_SecurityConfig sec; + TEST_ASSERT_TRUE(decodeSecurityFromReply(admin->reply(), sec)); +#if MESHTASTIC_EXCLUDE_PKI || MESHTASTIC_EXCLUDE_XEDDSA + TEST_ASSERT_EQUAL(meshtastic_Config_SecurityConfig_PacketSignaturePolicy_PACKET_SIGNATURE_POLICY_COMPATIBLE, + sec.packet_signature_policy); +#else + TEST_ASSERT_EQUAL(meshtastic_Config_SecurityConfig_PacketSignaturePolicy_PACKET_SIGNATURE_POLICY_STRICT, + sec.packet_signature_policy); +#endif + admin->drainReply(); +} + +// Decode the NetworkConfig / MqttConfig out of the response a handler queued in myReply. +static bool decodeNetworkFromReply(meshtastic_MeshPacket *reply, meshtastic_Config_NetworkConfig &out) +{ + meshtastic_AdminMessage am = meshtastic_AdminMessage_init_zero; + if (!reply || reply->which_payload_variant != meshtastic_MeshPacket_decoded_tag) + return false; + if (!pb_decode_from_bytes(reply->decoded.payload.bytes, reply->decoded.payload.size, &meshtastic_AdminMessage_msg, &am)) + return false; + if (am.which_payload_variant != meshtastic_AdminMessage_get_config_response_tag || + am.get_config_response.which_payload_variant != meshtastic_Config_network_tag) + return false; + out = am.get_config_response.payload_variant.network; + return true; +} + +static bool decodeMqttFromReply(meshtastic_MeshPacket *reply, meshtastic_ModuleConfig_MQTTConfig &out) +{ + meshtastic_AdminMessage am = meshtastic_AdminMessage_init_zero; + if (!reply || reply->which_payload_variant != meshtastic_MeshPacket_decoded_tag) + return false; + if (!pb_decode_from_bytes(reply->decoded.payload.bytes, reply->decoded.payload.size, &meshtastic_AdminMessage_msg, &am)) + return false; + if (am.which_payload_variant != meshtastic_AdminMessage_get_module_config_response_tag || + am.get_module_config_response.which_payload_variant != meshtastic_ModuleConfig_mqtt_tag) + return false; + out = am.get_module_config_response.payload_variant.mqtt; + return true; +} + +// A remote requester gets the sentinel; the set path swaps the stored value back. +void test_remote_network_config_omits_wifi_psk(void) +{ + strcpy(config.network.wifi_psk, "hunter2hunter2"); + + meshtastic_MeshPacket req = makeGetConfigRequest(ADMIN_NODE); + admin->handleGetConfig(req, meshtastic_AdminMessage_ConfigType_NETWORK_CONFIG); + + meshtastic_Config_NetworkConfig net; + TEST_ASSERT_TRUE(decodeNetworkFromReply(admin->reply(), net)); + TEST_ASSERT_EQUAL_STRING_MESSAGE("sekrit", net.wifi_psk, "remote network config must not carry the real psk"); + admin->drainReply(); +} + +// Control: the local path still receives the stored psk. +void test_local_network_config_keeps_wifi_psk(void) +{ + strcpy(config.network.wifi_psk, "hunter2hunter2"); + + meshtastic_MeshPacket req = makeGetConfigRequest(0); + admin->handleGetConfig(req, meshtastic_AdminMessage_ConfigType_NETWORK_CONFIG); + + meshtastic_Config_NetworkConfig net; + TEST_ASSERT_TRUE(decodeNetworkFromReply(admin->reply(), net)); + TEST_ASSERT_EQUAL_STRING_MESSAGE("hunter2hunter2", net.wifi_psk, "local client must still receive the psk"); + admin->drainReply(); +} + +void test_remote_mqtt_config_omits_password(void) +{ + strcpy(moduleConfig.mqtt.password, "brokerpass"); + + meshtastic_MeshPacket req = makeGetConfigRequest(ADMIN_NODE); + admin->handleGetModuleConfig(req, meshtastic_AdminMessage_ModuleConfigType_MQTT_CONFIG); + + meshtastic_ModuleConfig_MQTTConfig mqtt; + TEST_ASSERT_TRUE(decodeMqttFromReply(admin->reply(), mqtt)); + TEST_ASSERT_EQUAL_STRING_MESSAGE("sekrit", mqtt.password, "remote mqtt config must not carry the broker password"); + admin->drainReply(); +} + +void test_local_mqtt_config_keeps_password(void) +{ + strcpy(moduleConfig.mqtt.password, "brokerpass"); + + meshtastic_MeshPacket req = makeGetConfigRequest(0); + admin->handleGetModuleConfig(req, meshtastic_AdminMessage_ModuleConfigType_MQTT_CONFIG); + + meshtastic_ModuleConfig_MQTTConfig mqtt; + TEST_ASSERT_TRUE(decodeMqttFromReply(admin->reply(), mqtt)); + TEST_ASSERT_EQUAL_STRING_MESSAGE("brokerpass", mqtt.password, "local client must still receive the password"); + admin->drainReply(); +} + +// A client that GETs and writes the config straight back must not wipe the stored value. +void test_set_config_sentinel_psk_preserves_stored_value(void) +{ + strcpy(config.network.wifi_psk, "hunter2hunter2"); + + meshtastic_Config c = meshtastic_Config_init_zero; + c.which_payload_variant = meshtastic_Config_network_tag; + c.payload_variant.network = config.network; + strcpy(c.payload_variant.network.wifi_psk, "sekrit"); + + admin->deferSaves(); + admin->handleSetConfig(c, true); + + TEST_ASSERT_EQUAL_STRING_MESSAGE("hunter2hunter2", config.network.wifi_psk, + "a read-modify-write round trip must not wipe the psk"); + admin->drainReply(); +} + // An admin response carries no session passkey and its sender is not an admin-key holder, so a // request we sent is the only thing vouching for it. A get_module_config_response from a node we // never queried is not. @@ -310,38 +490,104 @@ void test_response_variant_must_match_request(void) // must not relax the PKC pin of an earlier one (the old shared-slot model cleared it). void test_pinned_request_keeps_its_key_after_an_unpinned_request(void) { - uint8_t key[32]; - memset(key, 0xC1, 32); + // QUERIED has a stored key, so a request to it will be PKC-encrypted and pins that key. + // STRANGER has none, so a request to it cannot be pinned. + mockNodeDB->addNodeWithKey(QUERIED_NODE, QUERIED_KEY); + mockNodeDB->addNodeWithKey(STRANGER_NODE, nullptr); - // A PKC-pinned get_config request to STRANGER (pins `key`). meshtastic_AdminMessage cfg = meshtastic_AdminMessage_init_zero; cfg.which_payload_variant = meshtastic_AdminMessage_get_config_request_tag; - meshtastic_MeshPacket pinned = makeOutgoingRequest(STRANGER_NODE, cfg); - pinned.pki_encrypted = true; - pinned.public_key.size = 32; - memcpy(pinned.public_key.bytes, key, 32); - admin->noteOutgoingAdminRequest(pinned); + admin->noteOutgoingAdminRequest(makeOutgoingRequest(QUERIED_NODE, cfg)); - // A later, unpinned get_owner request to the same node. meshtastic_AdminMessage own = meshtastic_AdminMessage_init_zero; own.which_payload_variant = meshtastic_AdminMessage_get_owner_request_tag; admin->noteOutgoingAdminRequest(makeOutgoingRequest(STRANGER_NODE, own)); meshtastic_AdminMessage m; - meshtastic_MeshPacket resp = makeModuleConfigResponse(STRANGER_NODE, m); // from set; pki off by default + meshtastic_MeshPacket resp = makeModuleConfigResponse(QUERIED_NODE, m); // pki off by default - // A plaintext get_config_response is still rejected: the config request was pinned. TEST_ASSERT_FALSE_MESSAGE(admin->responseIsSolicited(resp, meshtastic_AdminMessage_get_config_response_tag, 0), "an unpinned request must not relax an earlier request's key pin"); - // Over PKC with the pinned key it is accepted. + resp.pki_encrypted = true; resp.public_key.size = 32; - memcpy(resp.public_key.bytes, key, 32); + memcpy(resp.public_key.bytes, QUERIED_KEY, 32); TEST_ASSERT_TRUE(admin->responseIsSolicited(resp, meshtastic_AdminMessage_get_config_response_tag, 0)); - // The unpinned get_owner_response is accepted in plaintext (its request carried no pin). - resp.pki_encrypted = false; - resp.public_key.size = 0; - TEST_ASSERT_TRUE(admin->responseIsSolicited(resp, meshtastic_AdminMessage_get_owner_response_tag, 0)); +} + +// The pin is taken from the destination's stored NodeDB key - the key perhapsEncode will encrypt +// to. Reading the outgoing packet's public_key instead pinned nothing: nothing populates that +// field before encryption, so every real request was unpinned and `from` alone admitted responses. +void test_request_to_keyed_node_pins_the_stored_key(void) +{ + mockNodeDB->addNodeWithKey(QUERIED_NODE, QUERIED_KEY); + admin->noteOutgoingAdminRequest(makeOutgoingModuleConfigRequest(QUERIED_NODE)); + + meshtastic_AdminMessage m; + meshtastic_MeshPacket plain = makeModuleConfigResponse(QUERIED_NODE, m); + TEST_ASSERT_FALSE_MESSAGE(admin->responseIsSolicited(plain, MODULE_CONFIG_RESPONSE, REMOTE_HW_TAG), + "a plaintext response must not answer a PKC-pinned request"); + + meshtastic_MeshPacket wrongKey = makeModuleConfigResponse(QUERIED_NODE, m); + wrongKey.pki_encrypted = true; + wrongKey.public_key.size = 32; + memset(wrongKey.public_key.bytes, 0x77, 32); + TEST_ASSERT_FALSE_MESSAGE(admin->responseIsSolicited(wrongKey, MODULE_CONFIG_RESPONSE, REMOTE_HW_TAG), + "a response under a different key must not be accepted"); + + meshtastic_MeshPacket good = makeModuleConfigResponse(QUERIED_NODE, m); + good.pki_encrypted = true; + good.public_key.size = 32; + memcpy(good.public_key.bytes, QUERIED_KEY, 32); + TEST_ASSERT_TRUE_MESSAGE(admin->responseIsSolicited(good, MODULE_CONFIG_RESPONSE, REMOTE_HW_TAG), + "the pinned key must still admit the genuine response"); +} + +// Ham mode never uses PKC, so pinning a key there would reject the legitimate plaintext response. +void test_ham_mode_request_is_not_pinned(void) +{ + owner.is_licensed = true; + mockNodeDB->addNodeWithKey(QUERIED_NODE, QUERIED_KEY); + admin->noteOutgoingAdminRequest(makeOutgoingModuleConfigRequest(QUERIED_NODE)); + + meshtastic_AdminMessage m; + meshtastic_MeshPacket mp = makeModuleConfigResponse(QUERIED_NODE, m); + TEST_ASSERT_TRUE_MESSAGE(admin->responseIsSolicited(mp, MODULE_CONFIG_RESPONSE, REMOTE_HW_TAG), + "a request that could not have gone out over PKC must not be pinned"); +} + +// The response must echo our request's packet id, so an injector cannot answer a request it did +// not see just by naming the right node and variant. +void test_response_with_wrong_request_id_is_rejected(void) +{ + admin->noteOutgoingAdminRequest(makeOutgoingModuleConfigRequest(STRANGER_NODE)); + + meshtastic_AdminMessage m; + meshtastic_MeshPacket mp = makeModuleConfigResponse(STRANGER_NODE, m); + mp.decoded.request_id = REQUEST_ID ^ 0xFFFF; // answers some other request + + TEST_ASSERT_FALSE_MESSAGE(admin->responseIsSolicited(mp, MODULE_CONFIG_RESPONSE, REMOTE_HW_TAG), + "a response that does not echo our request id must be rejected"); + + mp.decoded.request_id = REQUEST_ID; + TEST_ASSERT_TRUE_MESSAGE(admin->responseIsSolicited(mp, MODULE_CONFIG_RESPONSE, REMOTE_HW_TAG), + "the matching request id must still be accepted"); +} + +// A request we could not bind to an id must not be answerable by a response that simply omits +// request_id, which decodes to 0. +void test_request_without_an_id_admits_nothing(void) +{ + meshtastic_MeshPacket req = makeOutgoingModuleConfigRequest(STRANGER_NODE); + req.id = 0; + admin->noteOutgoingAdminRequest(req); + + meshtastic_AdminMessage m; + meshtastic_MeshPacket mp = makeModuleConfigResponse(STRANGER_NODE, m); + mp.decoded.request_id = 0; + + TEST_ASSERT_FALSE_MESSAGE(admin->responseIsSolicited(mp, MODULE_CONFIG_RESPONSE, REMOTE_HW_TAG), + "a zero request id must not act as a matching token"); } // A remote_hardware response must answer a request for that exact subtype, not just any module @@ -431,11 +677,21 @@ void setup() RUN_TEST(test_session_gate_accepts_key_from_a_get_response); RUN_TEST(test_remote_security_config_omits_private_key); RUN_TEST(test_local_security_config_keeps_private_key); + RUN_TEST(test_local_security_config_applies_packet_signature_policy); + RUN_TEST(test_remote_network_config_omits_wifi_psk); + RUN_TEST(test_local_network_config_keeps_wifi_psk); + RUN_TEST(test_remote_mqtt_config_omits_password); + RUN_TEST(test_local_mqtt_config_keeps_password); + RUN_TEST(test_set_config_sentinel_psk_preserves_stored_value); RUN_TEST(test_unsolicited_response_is_not_solicited); RUN_TEST(test_response_after_our_request_is_solicited); RUN_TEST(test_request_to_one_node_does_not_admit_another); RUN_TEST(test_response_variant_must_match_request); RUN_TEST(test_pinned_request_keeps_its_key_after_an_unpinned_request); + RUN_TEST(test_request_to_keyed_node_pins_the_stored_key); + RUN_TEST(test_ham_mode_request_is_not_pinned); + RUN_TEST(test_response_with_wrong_request_id_is_rejected); + RUN_TEST(test_request_without_an_id_admits_nothing); RUN_TEST(test_module_config_subtype_must_match); RUN_TEST(test_response_is_consumed_no_replay); RUN_TEST(test_outgoing_setter_does_not_admit_responses); diff --git a/test/test_event_profile_storage/test_main.cpp b/test/test_event_profile_storage/test_main.cpp new file mode 100644 index 000000000..f38a41969 --- /dev/null +++ b/test/test_event_profile_storage/test_main.cpp @@ -0,0 +1,124 @@ +#include "MeshTypes.h" // Include BEFORE TestUtil.h +#include "TestUtil.h" +#include "mesh/NodeDB.h" +#include + +void test_standard_profile_uses_standard_files() +{ + const auto paths = radioProfileStoragePaths(false); + + TEST_ASSERT_EQUAL_STRING("/prefs/config.proto", paths.config); + TEST_ASSERT_EQUAL_STRING("/prefs/channels.proto", paths.channels); + TEST_ASSERT_EQUAL_STRING("/backups/backup.proto", paths.backup); +} + +void test_event_profile_uses_isolated_files() +{ + const auto paths = radioProfileStoragePaths(true); + + TEST_ASSERT_EQUAL_STRING("/prefs/event-config.proto", paths.config); + TEST_ASSERT_EQUAL_STRING("/prefs/event-channels.proto", paths.channels); + TEST_ASSERT_EQUAL_STRING("/backups/event-backup.proto", paths.backup); +} + +void test_boot_defers_persistence_until_config_is_verified() +{ + TEST_ASSERT_TRUE(shouldDeferBootPersistence(true, false, false)); + TEST_ASSERT_TRUE(shouldDeferBootPersistence(true, true, true)); + TEST_ASSERT_FALSE(shouldDeferBootPersistence(true, true, false)); + TEST_ASSERT_FALSE(shouldDeferBootPersistence(false, true, true)); +} + +void test_event_profile_requires_room_for_atomic_profile_writes() +{ + TEST_ASSERT_TRUE(hasEventProfileStorageSpace(EVENT_PROFILE_STORAGE_RESERVATION_BYTES, 0)); + TEST_ASSERT_FALSE(hasEventProfileStorageSpace(EVENT_PROFILE_STORAGE_RESERVATION_BYTES - 1, 0)); + TEST_ASSERT_FALSE(hasEventProfileStorageSpace(EVENT_PROFILE_STORAGE_RESERVATION_BYTES, 1)); + TEST_ASSERT_FALSE(hasEventProfileStorageSpace(1, 2)); + // A full filesystem must never look like it has room. + TEST_ASSERT_FALSE( + hasEventProfileStorageSpace(EVENT_PROFILE_STORAGE_RESERVATION_BYTES, EVENT_PROFILE_STORAGE_RESERVATION_BYTES)); +} + +// The whole point of the feature is that an event build never touches the user's real radio profile +// or the shared files. A copy/paste slip in the path table would silently overwrite them, which no +// amount of runtime testing would catch on a device that had never held a normal profile. +void test_event_paths_never_collide_with_standard_or_shared_files() +{ + const auto standard = radioProfileStoragePaths(false); + const auto event = radioProfileStoragePaths(true); + + TEST_ASSERT_NOT_EQUAL(0, strcmp(event.config, standard.config)); + TEST_ASSERT_NOT_EQUAL(0, strcmp(event.channels, standard.channels)); + TEST_ASSERT_NOT_EQUAL(0, strcmp(event.backup, standard.backup)); + + // The three event paths must also be distinct from each other. + TEST_ASSERT_NOT_EQUAL(0, strcmp(event.config, event.channels)); + TEST_ASSERT_NOT_EQUAL(0, strcmp(event.config, event.backup)); + TEST_ASSERT_NOT_EQUAL(0, strcmp(event.channels, event.backup)); + + // Files shared by both profiles must never be captured by either profile's paths. + const char *sharedFiles[] = {deviceStateFileName, nodeDatabaseFileName, moduleConfigFileName, uiconfigFileName, + legacyPrefFileName}; + for (const char *shared : sharedFiles) { + TEST_ASSERT_NOT_EQUAL(0, strcmp(event.config, shared)); + TEST_ASSERT_NOT_EQUAL(0, strcmp(event.channels, shared)); + TEST_ASSERT_NOT_EQUAL(0, strcmp(event.backup, shared)); + TEST_ASSERT_NOT_EQUAL(0, strcmp(standard.config, shared)); + TEST_ASSERT_NOT_EQUAL(0, strcmp(standard.channels, shared)); + TEST_ASSERT_NOT_EQUAL(0, strcmp(standard.backup, shared)); + } +} + +// Boot-write deferral must cover the radio profile and nothing else: widening it silently drops +// boot-time recovery writes (devicestate/nodedb) that are never retried. +void test_only_radio_profile_files_defer_boot_writes() +{ + TEST_ASSERT_TRUE(isRadioProfileFile(configFileName)); + TEST_ASSERT_TRUE(isRadioProfileFile(channelFileName)); + TEST_ASSERT_TRUE(isRadioProfileFile(backupFileName)); + + TEST_ASSERT_FALSE(isRadioProfileFile(deviceStateFileName)); + TEST_ASSERT_FALSE(isRadioProfileFile(nodeDatabaseFileName)); + TEST_ASSERT_FALSE(isRadioProfileFile(moduleConfigFileName)); + TEST_ASSERT_FALSE(isRadioProfileFile(uiconfigFileName)); +} + +// The reservation is compile-time, but the filesystems it has to fit in are tiny: nRF52840 +// InternalFS is 28 KiB and the STM32WL family is 14 KiB. If protobuf growth pushes the reservation +// past what those can spare, event builds silently stop persisting their profile. +void test_event_reservation_fits_smallest_supported_filesystem() +{ + constexpr size_t STM32WL_FILESYSTEM_BYTES = 14 * 1024; + constexpr size_t NRF52840_FILESYSTEM_BYTES = 28 * 1024; + + TEST_ASSERT_EQUAL_UINT32(2 * (meshtastic_LocalConfig_size + meshtastic_ChannelFile_size), + EVENT_PROFILE_STORAGE_RESERVATION_BYTES); + + // Leave at least half of the smallest filesystem for everything else under /prefs. + TEST_ASSERT_LESS_THAN_UINT32(STM32WL_FILESYSTEM_BYTES / 2, EVENT_PROFILE_STORAGE_RESERVATION_BYTES); + TEST_ASSERT_TRUE(hasEventProfileStorageSpace(NRF52840_FILESYSTEM_BYTES, 0)); + TEST_ASSERT_TRUE(hasEventProfileStorageSpace(STM32WL_FILESYSTEM_BYTES, 0)); +} + +void setUp(void) {} + +void tearDown(void) {} + +extern "C" { +void setup() +{ + initializeTestEnvironment(); + UNITY_BEGIN(); + RUN_TEST(test_standard_profile_uses_standard_files); + RUN_TEST(test_event_profile_uses_isolated_files); + RUN_TEST(test_boot_defers_persistence_until_config_is_verified); + RUN_TEST(test_event_profile_requires_room_for_atomic_profile_writes); + RUN_TEST(test_event_paths_never_collide_with_standard_or_shared_files); + RUN_TEST(test_only_radio_profile_files_defer_boot_writes); + RUN_TEST(test_event_reservation_fits_smallest_supported_filesystem); + exit(UNITY_END()); +} + +void loop() {} +} diff --git a/test/test_fuzz_packets/test_main.cpp b/test/test_fuzz_packets/test_main.cpp index c65b5460e..c9edcabb1 100644 --- a/test/test_fuzz_packets/test_main.cpp +++ b/test/test_fuzz_packets/test_main.cpp @@ -159,7 +159,8 @@ void test_E1_perhaps_decode_fuzz(void) DecodeState st = perhapsDecode(&p); // Any verdict is fine; the contract is that arbitrary ciphertext never crashes the pipeline. - TEST_ASSERT_TRUE(st == DECODE_SUCCESS || st == DECODE_FAILURE || st == DECODE_FATAL); + TEST_ASSERT_TRUE(st == DECODE_SUCCESS || st == DECODE_FAILURE || st == DECODE_OPAQUE || st == DECODE_FATAL || + st == DECODE_POLICY_REJECT); } } diff --git a/test/test_mesh_module/test_main.cpp b/test/test_mesh_module/test_main.cpp index ec7b1808e..a39988064 100644 --- a/test/test_mesh_module/test_main.cpp +++ b/test/test_mesh_module/test_main.cpp @@ -3,6 +3,23 @@ #include "TestUtil.h" #include +#include "configuration.h" +#include "mesh/CryptoEngine.h" +#include "mesh/MeshService.h" +#include "mesh/NodeDB.h" +#include "mesh/RadioInterface.h" +#include "mesh/Router.h" +#include "modules/NeighborInfoModule.h" +#include "modules/RoutingModule.h" +#include "support/MockMeshService.h" +#include +#include + +namespace +{ +constexpr NodeNum LOCAL_NODE = 0x11111111; +constexpr NodeNum REMOTE_NODE = 0x22222222; + // Minimal concrete subclass for testing the base class helper class TestModule : public MeshModule { @@ -13,11 +30,312 @@ class TestModule : public MeshModule using MeshModule::isMultiHopBroadcastRequest; }; +class MockNodeDB : public NodeDB +{ +}; + +class MockRadioInterface : public RadioInterface +{ + public: + ErrorCode send(meshtastic_MeshPacket *p) override + { + packetPool.release(p); + return ERRNO_OK; + } + + uint32_t getPacketTime(uint32_t totalPacketLen, bool received = false) override + { + (void)totalPacketLen; + (void)received; + return 0; + } +}; + +class MockRouter : public Router +{ + public: + ~MockRouter() + { + delete cryptLock; + cryptLock = nullptr; + } + + ErrorCode send(meshtastic_MeshPacket *p) override + { + sentPackets.push_back(*p); + packetPool.release(p); + return ERRNO_OK; + } + + std::vector sentPackets; +}; + +struct AckNak { + meshtastic_Routing_Error error; + NodeNum to; + PacketId requestId; + ChannelIndex channel; +}; + +class MockRoutingModule : public RoutingModule +{ + public: + void sendAckNak(meshtastic_Routing_Error err, NodeNum to, PacketId idFrom, ChannelIndex chIndex, uint8_t hopLimit = 0, + bool ackWantsAck = false) override + { + (void)hopLimit; + (void)ackWantsAck; + ackNaks.push_back({err, to, idFrom, chIndex}); + } + + std::vector ackNaks; + + protected: + bool wantPacket(const meshtastic_MeshPacket *p) override + { + (void)p; + return false; + } +}; + +class SyntheticReplyModule : public MeshModule +{ + public: + SyntheticReplyModule(const char *name, meshtastic_PortNum modulePort, meshtastic_PortNum replyPort, + bool acceptsEveryPort = false) + : MeshModule(name, modulePort), replyPort(replyPort), acceptsEveryPort(acceptsEveryPort) + { + isPromiscuous = acceptsEveryPort; + } + + uint32_t allocReplyCalls = 0; + + protected: + bool wantPacket(const meshtastic_MeshPacket *p) override { return acceptsEveryPort || p->decoded.portnum == ourPortNum; } + + meshtastic_MeshPacket *allocReply() override + { + allocReplyCalls++; + meshtastic_MeshPacket *reply = router->allocForSending(); + reply->decoded.portnum = replyPort; + return reply; + } + + private: + meshtastic_PortNum replyPort; + bool acceptsEveryPort; +}; + +class ObservingIgnoreModule : public MeshModule +{ + public: + ObservingIgnoreModule() : MeshModule("storeforward-shaped", meshtastic_PortNum_STORE_FORWARD_APP) {} + + uint32_t allocReplyCalls = 0; + + protected: + bool wantPacket(const meshtastic_MeshPacket *p) override { return p->decoded.portnum == meshtastic_PortNum_TEXT_MESSAGE_APP; } + + ProcessMessage handleReceived(const meshtastic_MeshPacket &mp) override + { + (void)mp; + ignoreRequest = true; + return ProcessMessage::CONTINUE; + } + + meshtastic_MeshPacket *allocReply() override + { + allocReplyCalls++; + return nullptr; + } +}; + +class ReplyIgnoreModule : public MeshModule +{ + public: + ReplyIgnoreModule() : MeshModule("reply-ignore", meshtastic_PortNum_NEIGHBORINFO_APP) {} + + uint32_t allocReplyCalls = 0; + + protected: + bool wantPacket(const meshtastic_MeshPacket *p) override { return p->decoded.portnum == ourPortNum; } + + meshtastic_MeshPacket *allocReply() override + { + allocReplyCalls++; + ignoreRequest = true; + return nullptr; + } +}; + +// Sends, from inside callModules(), a self-addressed reply and a local broadcast - the fan-out an +// AdminModule config save performs. Both go out through service->sendToMesh() while a +// handleReceived() frame is live, so both must be deferred rather than handled re-entrantly. +class NestedFanoutModule : public MeshModule +{ + public: + NestedFanoutModule() : MeshModule("nested-fanout", meshtastic_PortNum_PRIVATE_APP) {} + uint32_t handleCalls = 0; + + protected: + bool wantPacket(const meshtastic_MeshPacket *p) override { return p->decoded.portnum == ourPortNum; } + + ProcessMessage handleReceived(const meshtastic_MeshPacket &mp) override + { + (void)mp; + handleCalls++; + + // (a) a reply addressed to ourselves - exercises the isToUs() deferral branch + meshtastic_MeshPacket *reply = router->allocForSending(); + reply->to = LOCAL_NODE; + reply->decoded.portnum = meshtastic_PortNum_TEXT_MESSAGE_APP; + reply->decoded.request_id = 0xBEEF; + service->sendToMesh(reply); // default src == RX_SRC_LOCAL + + // (b) a local broadcast - exercises the broadcast branch (loopback deferred, TX immediate) + meshtastic_MeshPacket *bcast = router->allocForSending(); + bcast->to = NODENUM_BROADCAST; + bcast->decoded.portnum = meshtastic_PortNum_TEXT_MESSAGE_APP; + service->sendToMesh(bcast); + + return ProcessMessage::STOP; + } +}; + +// loopbackOk so it also runs for drained RX_SRC_LOCAL packets; each generation sends the next, so +// the drain must keep processing a chain that grows while it is draining. +class ChainSendModule : public MeshModule +{ + public: + ChainSendModule() : MeshModule("chain-send", meshtastic_PortNum_PRIVATE_APP) { loopbackOk = true; } + uint32_t handleCalls = 0; + uint32_t maxGeneration = 0; + + protected: + bool wantPacket(const meshtastic_MeshPacket *p) override { return p->decoded.portnum == ourPortNum; } + + ProcessMessage handleReceived(const meshtastic_MeshPacket &mp) override + { + handleCalls++; + uint32_t generation = mp.decoded.request_id; + if (generation > maxGeneration) + maxGeneration = generation; + + if (generation < 3) { + meshtastic_MeshPacket *next = router->allocForSending(); + next->to = LOCAL_NODE; + next->decoded.portnum = ourPortNum; + next->decoded.request_id = generation + 1; + service->sendToMesh(next); // default src == RX_SRC_LOCAL + } + return ProcessMessage::STOP; + } +}; + +// Sends a burst of self-addressed replies from one dispatch, to overflow the deferred queue. +class BurstSendModule : public MeshModule +{ + public: + explicit BurstSendModule(uint32_t count) : MeshModule("burst-send", meshtastic_PortNum_PRIVATE_APP), count(count) {} + + protected: + bool wantPacket(const meshtastic_MeshPacket *p) override { return p->decoded.portnum == ourPortNum; } + + ProcessMessage handleReceived(const meshtastic_MeshPacket &mp) override + { + (void)mp; + for (uint32_t i = 0; i < count; i++) { + meshtastic_MeshPacket *reply = router->allocForSending(); + reply->to = LOCAL_NODE; + reply->decoded.portnum = meshtastic_PortNum_TEXT_MESSAGE_APP; + reply->decoded.request_id = 0x100 + i; + service->sendToMesh(reply); // default src == RX_SRC_LOCAL + } + return ProcessMessage::STOP; + } + + private: + uint32_t count; +}; + static TestModule *testModule; static meshtastic_MeshPacket testPacket; +static MockNodeDB *mockNodeDB; +static MockMeshService *mockService; +static MockRouter *mockRouter; +static MockRoutingModule *mockRoutingModule; +static NeighborInfoModule *realNeighborInfoModule; +static std::vector dispatchModules; + +template static T *registerDispatchModule(T *module) +{ + dispatchModules.push_back(module); + return module; +} + +static meshtastic_MeshPacket makeRequest(meshtastic_PortNum port) +{ + meshtastic_MeshPacket packet = meshtastic_MeshPacket_init_zero; + packet.from = REMOTE_NODE; + packet.to = LOCAL_NODE; + packet.id = 0x12345678; + packet.channel = 0; + packet.hop_start = 3; + packet.hop_limit = 3; + packet.which_payload_variant = meshtastic_MeshPacket_decoded_tag; + packet.decoded.portnum = port; + packet.decoded.want_response = true; + return packet; +} + +static void dispatch(meshtastic_PortNum port) +{ + meshtastic_MeshPacket request = makeRequest(port); + MeshModule::callModules(request); +} + +// Dispatch a want_response==false trigger addressed to us through the real router, so a module's +// handler runs inside a live handleReceived() frame (handleDepth == 1) and any packet it sends is +// deferred. requestId is carried in decoded.request_id for modules that key on a generation. +static void dispatchTrigger(meshtastic_PortNum port, uint32_t requestId = 0) +{ + meshtastic_MeshPacket trigger = meshtastic_MeshPacket_init_zero; + trigger.from = LOCAL_NODE; + trigger.to = LOCAL_NODE; + trigger.id = 0x7A190000 + requestId; + trigger.which_payload_variant = meshtastic_MeshPacket_decoded_tag; + trigger.decoded.portnum = port; + trigger.decoded.request_id = requestId; + service->sendToMesh(packetPool.allocCopy(trigger), RX_SRC_USER); +} + +} // namespace void setUp(void) { + config = meshtastic_LocalConfig_init_zero; + moduleConfig = meshtastic_LocalModuleConfig_init_zero; + channelFile = meshtastic_ChannelFile_init_zero; + owner = meshtastic_User_init_zero; + myNodeInfo.my_node_num = LOCAL_NODE; + + mockNodeDB = new MockNodeDB(); + nodeDB = mockNodeDB; + myNodeInfo.my_node_num = LOCAL_NODE; + + mockService = new MockMeshService(); + service = mockService; + + channels.initDefaults(); + channels.onConfigChanged(); + + mockRouter = new MockRouter(); + mockRouter->addInterface(std::unique_ptr(new MockRadioInterface())); + router = mockRouter; + + mockRoutingModule = new MockRoutingModule(); + routingModule = mockRoutingModule; + testModule = new TestModule(); memset(&testPacket, 0, sizeof(testPacket)); TestModule::currentRequest = &testPacket; @@ -26,7 +344,36 @@ void setUp(void) void tearDown(void) { TestModule::currentRequest = NULL; + + for (auto it = dispatchModules.rbegin(); it != dispatchModules.rend(); ++it) + delete *it; + dispatchModules.clear(); + + delete realNeighborInfoModule; + realNeighborInfoModule = nullptr; + delete testModule; + testModule = nullptr; + + delete mockRoutingModule; + mockRoutingModule = nullptr; + routingModule = nullptr; + + while (auto *status = mockService->getQueueStatusForPhone()) + mockService->releaseQueueStatusToPool(status); + while (auto *toPhone = mockService->getForPhone()) + mockService->releaseToPool(toPhone); + delete mockService; + mockService = nullptr; + service = nullptr; + + delete mockRouter; + mockRouter = nullptr; + router = nullptr; + + delete mockNodeDB; + mockNodeDB = nullptr; + nodeDB = nullptr; } // Zero-hop broadcast (hop_limit == hop_start): should be allowed @@ -98,6 +445,272 @@ static void test_singleHopRelayedBroadcast_isBlocked() TEST_ASSERT_TRUE(testModule->isMultiHopBroadcastRequest()); } +static void test_replyPortMatches_ownPort() +{ + meshtastic_MeshPacket request = makeRequest(meshtastic_PortNum_TELEMETRY_APP); + + TEST_ASSERT_TRUE(MeshModule::replyPortMatches(meshtastic_PortNum_TELEMETRY_APP, request)); +} + +static void test_replyPortMatches_neighborInfoVsTelemetry() +{ + meshtastic_MeshPacket request = makeRequest(meshtastic_PortNum_TELEMETRY_APP); + + TEST_ASSERT_FALSE(MeshModule::replyPortMatches(meshtastic_PortNum_NEIGHBORINFO_APP, request)); +} + +static void test_replyPortMatches_positionRequest() +{ + meshtastic_MeshPacket request = makeRequest(meshtastic_PortNum_POSITION_APP); + + TEST_ASSERT_TRUE(MeshModule::replyPortMatches(meshtastic_PortNum_POSITION_APP, request)); +} + +static void test_replyPortMatches_unknownModulePort() +{ + meshtastic_MeshPacket request = makeRequest(meshtastic_PortNum_TELEMETRY_APP); + + TEST_ASSERT_FALSE(MeshModule::replyPortMatches(meshtastic_PortNum_UNKNOWN_APP, request)); +} + +static void test_replyPortMatches_unknownRequestPort() +{ + meshtastic_MeshPacket request = makeRequest(meshtastic_PortNum_UNKNOWN_APP); + + TEST_ASSERT_FALSE(MeshModule::replyPortMatches(meshtastic_PortNum_TELEMETRY_APP, request)); +} + +static void test_dispatch_foreignPortOffenderCannotShadowOwner() +{ + auto *offender = registerDispatchModule(new SyntheticReplyModule("neighbor-shaped", meshtastic_PortNum_NEIGHBORINFO_APP, + meshtastic_PortNum_NEIGHBORINFO_APP, true)); + auto *owner = registerDispatchModule( + new SyntheticReplyModule("telemetry-owner", meshtastic_PortNum_TELEMETRY_APP, meshtastic_PortNum_TELEMETRY_APP)); + + dispatch(meshtastic_PortNum_TELEMETRY_APP); + + TEST_ASSERT_EQUAL_UINT32(0, offender->allocReplyCalls); + TEST_ASSERT_EQUAL_UINT32(1, owner->allocReplyCalls); + TEST_ASSERT_EQUAL_UINT32(1, mockRouter->sentPackets.size()); + TEST_ASSERT_EQUAL(meshtastic_PortNum_TELEMETRY_APP, mockRouter->sentPackets[0].decoded.portnum); + TEST_ASSERT_EQUAL_UINT32(0, mockRoutingModule->ackNaks.size()); +} + +static void test_dispatch_ownerPortStillReplies() +{ + auto *offender = registerDispatchModule(new SyntheticReplyModule("neighbor-shaped", meshtastic_PortNum_NEIGHBORINFO_APP, + meshtastic_PortNum_NEIGHBORINFO_APP, true)); + auto *owner = registerDispatchModule( + new SyntheticReplyModule("telemetry-owner", meshtastic_PortNum_TELEMETRY_APP, meshtastic_PortNum_TELEMETRY_APP)); + + dispatch(meshtastic_PortNum_NEIGHBORINFO_APP); + + TEST_ASSERT_EQUAL_UINT32(1, offender->allocReplyCalls); + TEST_ASSERT_EQUAL_UINT32(0, owner->allocReplyCalls); + TEST_ASSERT_EQUAL_UINT32(1, mockRouter->sentPackets.size()); + TEST_ASSERT_EQUAL(meshtastic_PortNum_NEIGHBORINFO_APP, mockRouter->sentPackets[0].decoded.portnum); + TEST_ASSERT_EQUAL_UINT32(0, mockRoutingModule->ackNaks.size()); +} + +static void test_dispatch_crossPortReplyUsesRequestOwner() +{ + auto *position = registerDispatchModule( + new SyntheticReplyModule("position-owner", meshtastic_PortNum_POSITION_APP, meshtastic_PortNum_ATAK_PLUGIN_V2)); + + dispatch(meshtastic_PortNum_POSITION_APP); + + TEST_ASSERT_EQUAL_UINT32(1, position->allocReplyCalls); + TEST_ASSERT_EQUAL_UINT32(1, mockRouter->sentPackets.size()); + TEST_ASSERT_EQUAL(meshtastic_PortNum_ATAK_PLUGIN_V2, mockRouter->sentPackets[0].decoded.portnum); + TEST_ASSERT_EQUAL_UINT32(0, mockRoutingModule->ackNaks.size()); +} + +static void test_dispatch_foreignPortObserverCanSuppressNak() +{ + auto *observer = registerDispatchModule(new ObservingIgnoreModule()); + + dispatch(meshtastic_PortNum_TEXT_MESSAGE_APP); + + TEST_ASSERT_EQUAL_UINT32(0, observer->allocReplyCalls); + TEST_ASSERT_EQUAL_UINT32(0, mockRouter->sentPackets.size()); + TEST_ASSERT_EQUAL_UINT32(0, mockRoutingModule->ackNaks.size()); +} + +static void test_dispatch_noResponderSendsNak() +{ + dispatch(meshtastic_PortNum_TELEMETRY_APP); + + TEST_ASSERT_EQUAL_UINT32(0, mockRouter->sentPackets.size()); + TEST_ASSERT_EQUAL_UINT32(1, mockRoutingModule->ackNaks.size()); + TEST_ASSERT_EQUAL(meshtastic_Routing_Error_NO_RESPONSE, mockRoutingModule->ackNaks[0].error); + TEST_ASSERT_EQUAL_HEX32(REMOTE_NODE, mockRoutingModule->ackNaks[0].to); +} + +static void test_dispatch_ignoreRequestIsClearedPerPacket() +{ + auto *ignoring = registerDispatchModule(new ReplyIgnoreModule()); + + dispatch(meshtastic_PortNum_NEIGHBORINFO_APP); + + TEST_ASSERT_EQUAL_UINT32(1, ignoring->allocReplyCalls); + TEST_ASSERT_EQUAL_UINT32(0, mockRouter->sentPackets.size()); + TEST_ASSERT_EQUAL_UINT32(0, mockRoutingModule->ackNaks.size()); + + dispatch(meshtastic_PortNum_TELEMETRY_APP); + + TEST_ASSERT_EQUAL_UINT32(1, ignoring->allocReplyCalls); + TEST_ASSERT_EQUAL_UINT32(0, mockRouter->sentPackets.size()); + TEST_ASSERT_EQUAL_UINT32(1, mockRoutingModule->ackNaks.size()); + TEST_ASSERT_EQUAL(meshtastic_Routing_Error_NO_RESPONSE, mockRoutingModule->ackNaks[0].error); +} + +static void test_dispatch_realNeighborInfoCannotShadowTelemetryOwner() +{ + moduleConfig.neighbor_info.enabled = true; + realNeighborInfoModule = new NeighborInfoModule(); + registerDispatchModule( + new SyntheticReplyModule("telemetry-owner", meshtastic_PortNum_TELEMETRY_APP, meshtastic_PortNum_TELEMETRY_APP)); + + dispatch(meshtastic_PortNum_TELEMETRY_APP); + + TEST_ASSERT_EQUAL_UINT32(1, mockRouter->sentPackets.size()); + TEST_ASSERT_EQUAL(meshtastic_PortNum_TELEMETRY_APP, mockRouter->sentPackets[0].decoded.portnum); + TEST_ASSERT_EQUAL_UINT32(0, mockRoutingModule->ackNaks.size()); +} + +// A reply addressed to ourselves reaches the phone queue, with SHOULD_RELEASE reported as success. +static void test_localReplyToSelf_isDeliveredToPhone() +{ + meshtastic_MeshPacket reply = meshtastic_MeshPacket_init_zero; + reply.from = LOCAL_NODE; + reply.to = LOCAL_NODE; + reply.id = 0xABCD1234; + reply.which_payload_variant = meshtastic_MeshPacket_decoded_tag; + reply.decoded.portnum = meshtastic_PortNum_ADMIN_APP; + reply.decoded.request_id = 0x12345678; + + service->sendToMesh(packetPool.allocCopy(reply)); // default src == RX_SRC_LOCAL, as callModules sends replies + + meshtastic_MeshPacket *toPhone = mockService->getForPhone(); + TEST_ASSERT_NOT_NULL(toPhone); + TEST_ASSERT_EQUAL_UINT32(0xABCD1234, toPhone->id); + TEST_ASSERT_EQUAL_UINT32(0x12345678, toPhone->decoded.request_id); + mockService->releaseToPool(toPhone); + TEST_ASSERT_NULL(mockService->getForPhone()); // exactly one delivery + + meshtastic_QueueStatus *qs = mockService->getQueueStatusForPhone(); + TEST_ASSERT_NOT_NULL(qs); + TEST_ASSERT_EQUAL(ERRNO_OK, qs->res); + mockService->releaseQueueStatusToPool(qs); + + TEST_ASSERT_EQUAL_UINT32(0, mockRouter->sentPackets.size()); // nothing went toward the radio +} + +// Full loop: a phone-originated want_response request (from == 0, RX_SRC_USER) dispatched +// through the real router must produce a module reply that reaches the phone queue. +static void test_phoneRequest_replyReachesPhone() +{ + auto *replyOwner = registerDispatchModule( + new SyntheticReplyModule("private-owner", meshtastic_PortNum_PRIVATE_APP, meshtastic_PortNum_PRIVATE_APP)); + + meshtastic_MeshPacket request = meshtastic_MeshPacket_init_zero; + request.from = 0; // phone-originated, as MeshService::handleToRadio stamps it + request.to = LOCAL_NODE; + request.id = 0x5EED0001; + request.which_payload_variant = meshtastic_MeshPacket_decoded_tag; + request.decoded.portnum = meshtastic_PortNum_PRIVATE_APP; + request.decoded.want_response = true; + + service->sendToMesh(packetPool.allocCopy(request), RX_SRC_USER); + + TEST_ASSERT_EQUAL_UINT32(1, replyOwner->allocReplyCalls); + + meshtastic_MeshPacket *toPhone = mockService->getForPhone(); + TEST_ASSERT_NOT_NULL(toPhone); + TEST_ASSERT_EQUAL(meshtastic_PortNum_PRIVATE_APP, toPhone->decoded.portnum); + TEST_ASSERT_EQUAL_UINT32(0x5EED0001, toPhone->decoded.request_id); + TEST_ASSERT_EQUAL_UINT32(LOCAL_NODE, toPhone->to); + mockService->releaseToPool(toPhone); + TEST_ASSERT_NULL(mockService->getForPhone()); // the request itself must not echo back + + // One QueueStatus for the request, one for the reply, both reporting success + uint32_t statuses = 0; + while (auto *qs = mockService->getQueueStatusForPhone()) { + TEST_ASSERT_EQUAL(ERRNO_OK, qs->res); + mockService->releaseQueueStatusToPool(qs); + statuses++; + } + TEST_ASSERT_EQUAL_UINT32(2, statuses); + + TEST_ASSERT_EQUAL_UINT32(0, mockRouter->sentPackets.size()); + TEST_ASSERT_EQUAL_UINT32(0, mockRoutingModule->ackNaks.size()); +} + +// A module that fans out a self-addressed reply and a local broadcast from inside callModules() +// must not re-enter handleReceived(): the sends are deferred and drained flat (max depth 1). +static void test_nestedLocalSend_isDeferred_notReentrant() +{ + auto *mod = registerDispatchModule(new NestedFanoutModule()); + + dispatchTrigger(meshtastic_PortNum_PRIVATE_APP); + + // The module ran once and was never re-entered; the drain left nothing behind. + TEST_ASSERT_EQUAL_UINT32(1, mod->handleCalls); + TEST_ASSERT_EQUAL_UINT8(1, mockRouter->maxHandleDepthObserved); + TEST_ASSERT_EQUAL_UINT8(0, mockRouter->deferredLocalPending()); + + // The self-addressed reply reached the phone exactly once (composition with #11185's ccToPhone). + meshtastic_MeshPacket *toPhone = mockService->getForPhone(); + TEST_ASSERT_NOT_NULL(toPhone); + TEST_ASSERT_EQUAL_UINT32(LOCAL_NODE, toPhone->to); + TEST_ASSERT_EQUAL_UINT32(0xBEEF, toPhone->decoded.request_id); + mockService->releaseToPool(toPhone); + TEST_ASSERT_NULL(mockService->getForPhone()); // the broadcast did not also go to the phone + + // The broadcast still reached the radio TX path exactly once. + TEST_ASSERT_EQUAL_UINT32(1, mockRouter->sentPackets.size()); + TEST_ASSERT_TRUE(isBroadcast(mockRouter->sentPackets[0].to)); +} + +// A drained packet whose own module sends again is picked up by the same drain pass, so a chain of +// local sends is processed breadth-first with the stack held flat (max depth 1). +static void test_deferredChain_drainsBreadthFirst() +{ + auto *mod = registerDispatchModule(new ChainSendModule()); + + dispatchTrigger(meshtastic_PortNum_PRIVATE_APP, 1); // generation 1 + + // Generations 1 -> 2 -> 3 were all processed in the single outermost drain, never nesting. + TEST_ASSERT_EQUAL_UINT32(3, mod->handleCalls); + TEST_ASSERT_EQUAL_UINT32(3, mod->maxGeneration); + TEST_ASSERT_EQUAL_UINT8(1, mockRouter->maxHandleDepthObserved); + TEST_ASSERT_EQUAL_UINT8(0, mockRouter->deferredLocalPending()); +} + +// Overflowing the fixed deferred queue drops the excess deferrals gracefully: no crash, no leak, +// depth still capped, and every send's phone cc still happens (return codes unchanged). +static void test_deferredQueueOverflow_dropsGracefully() +{ + const uint32_t burst = 6; // deferredLocalCapacity (4) + 2 - keep in sync with Router.h + + registerDispatchModule(new BurstSendModule(burst)); + + dispatchTrigger(meshtastic_PortNum_PRIVATE_APP); + + // Two deferrals were dropped (queue full) and nothing re-entered handleReceived(). + TEST_ASSERT_EQUAL_UINT32(2, mockRouter->deferredLocalDropped); + TEST_ASSERT_EQUAL_UINT8(1, mockRouter->maxHandleDepthObserved); + TEST_ASSERT_EQUAL_UINT8(0, mockRouter->deferredLocalPending()); // drained clean + + // Return codes were unchanged: every reply still cc'd to the phone, dropped deferral or not. + uint32_t delivered = 0; + while (auto *toPhone = mockService->getForPhone()) { + mockService->releaseToPool(toPhone); + delivered++; + } + TEST_ASSERT_EQUAL_UINT32(burst, delivered); +} + void setup() { initializeTestEnvironment(); @@ -110,6 +723,23 @@ void setup() RUN_TEST(test_noCurrentRequest_isAllowed); RUN_TEST(test_legacyPacket_zeroHopStart_isAllowed); RUN_TEST(test_singleHopRelayedBroadcast_isBlocked); + RUN_TEST(test_replyPortMatches_ownPort); + RUN_TEST(test_replyPortMatches_neighborInfoVsTelemetry); + RUN_TEST(test_replyPortMatches_positionRequest); + RUN_TEST(test_replyPortMatches_unknownModulePort); + RUN_TEST(test_replyPortMatches_unknownRequestPort); + RUN_TEST(test_dispatch_foreignPortOffenderCannotShadowOwner); + RUN_TEST(test_dispatch_ownerPortStillReplies); + RUN_TEST(test_dispatch_crossPortReplyUsesRequestOwner); + RUN_TEST(test_dispatch_foreignPortObserverCanSuppressNak); + RUN_TEST(test_dispatch_noResponderSendsNak); + RUN_TEST(test_dispatch_ignoreRequestIsClearedPerPacket); + RUN_TEST(test_dispatch_realNeighborInfoCannotShadowTelemetryOwner); + RUN_TEST(test_localReplyToSelf_isDeliveredToPhone); + RUN_TEST(test_phoneRequest_replyReachesPhone); + RUN_TEST(test_nestedLocalSend_isDeferred_notReentrant); + RUN_TEST(test_deferredChain_drainsBreadthFirst); + RUN_TEST(test_deferredQueueOverflow_dropsGracefully); exit(UNITY_END()); } diff --git a/test/test_module_config/test_main.cpp b/test/test_module_config/test_main.cpp new file mode 100644 index 000000000..37cc98b2a --- /dev/null +++ b/test/test_module_config/test_main.cpp @@ -0,0 +1,195 @@ +// AdminModule module-config lifecycle coverage: every submessage must survive set -> save -> load -> get. +// +// Motivated by firmware#11182 / Meshtastic-Android#6430 (TAK team/role never persisted): a submessage +// with no case in handleSetModuleConfig() falls through the switch silently - the setter still ACKs +// and reboots - and one missing has_* flag in saveToDisk() makes nanopb drop the struct on the flash +// write. Both defects are invisible to per-module tests until someone hits them in the field, so the +// sweeps below enumerate the generated constants and put each type through the full lifecycle. +// +// The sweeps assert presence and routing, not field semantics - per-module value validation belongs +// to the per-module suites (test_mesh_beacon, test_admin_session_repro, ...). + +#include "MeshTypes.h" // include BEFORE TestUtil.h +#include "TestUtil.h" +#include + +#include "mesh/NodeDB.h" +#include "mesh/mesh-pb-constants.h" +#include "modules/AdminModule.h" +#include "support/AdminModuleTestShim.h" +#include "support/MockMeshService.h" +#include +#include + +static constexpr NodeNum LOCAL_NODE = 0x0A0A0A0A; +static constexpr NodeNum ADMIN_NODE = 0x0B0B0B0B; + +static MockMeshService *mockService = nullptr; +static AdminModuleTestShim *admin = nullptr; + +// Both generated sets are contiguous: ModuleConfigType runs 0.._MAX and the matching ModuleConfig +// submessage tags run 1.._MAX+1, so type + 1 == tag and no lookup table is needed. +static const uint32_t moduleConfigTypeCount = _meshtastic_AdminMessage_ModuleConfigType_MAX + 1; + +// The submessages whose handlers are compiled out on some builds. Everything else must be present +// unconditionally. +static bool moduleConfigIsCompiledOut(pb_size_t tag) +{ + (void)tag; +#if MESHTASTIC_EXCLUDE_MQTT + if (tag == meshtastic_ModuleConfig_mqtt_tag) + return true; +#endif +#if MESHTASTIC_EXCLUDE_BEACON + if (tag == meshtastic_ModuleConfig_mesh_beacon_tag) + return true; +#endif + return false; +} + +// Unity assertion messages are plain strings, so name the failing iteration by number - nanopb keeps +// no field names to print. Cross-reference module_config.pb.h / admin.pb.h. +static const char *describeTag(const char *what, pb_size_t tag) +{ + static char buf[96]; + snprintf(buf, sizeof(buf), "%s: ModuleConfig submessage tag %u", what, (unsigned)tag); + return buf; +} + +static meshtastic_MeshPacket makeGetRequest(NodeNum from) +{ + meshtastic_MeshPacket req = meshtastic_MeshPacket_init_zero; + req.from = from; + req.which_payload_variant = meshtastic_MeshPacket_decoded_tag; + req.decoded.want_response = true; + return req; +} + +// Read back the variant tag of whatever get_module_config response the handler queued. +static bool decodeResponseTagFromReply(meshtastic_MeshPacket *reply, pb_size_t &out) +{ + meshtastic_AdminMessage am = meshtastic_AdminMessage_init_zero; + if (!reply || reply->which_payload_variant != meshtastic_MeshPacket_decoded_tag) + return false; + if (!pb_decode_from_bytes(reply->decoded.payload.bytes, reply->decoded.payload.size, &meshtastic_AdminMessage_msg, &am)) + return false; + if (am.which_payload_variant != meshtastic_AdminMessage_get_module_config_response_tag) + return false; + out = am.get_module_config_response.which_payload_variant; + return true; +} + +// The proto guarantees the two enumerations stay in step; the sweeps below rely on it to pair a +// ModuleConfigType with its submessage tag without a lookup table. +void test_tagRangeMatchesTypeRange(void) +{ + TEST_ASSERT_EQUAL_MESSAGE(moduleConfigTypeCount, meshtastic_ModuleConfig_mesh_beacon_tag, + "ModuleConfigType and ModuleConfig submessage tags are no longer 1:1"); +} + +// The full lifecycle per submessage: admin set -> encode (what saveToDisk writes) -> decode into the +// live global (a reboot's loadFromDisk) -> admin get answers with that submessage. A missing set case +// shows up as a 0-byte encode; a missing get case as an empty or mismatched response. +void test_everyModuleConfig_survivesSetSaveLoadGet(void) +{ + for (pb_size_t tag = 1; tag <= moduleConfigTypeCount; tag++) { + if (moduleConfigIsCompiledOut(tag)) + continue; + moduleConfig = meshtastic_LocalModuleConfig_init_zero; + + // Set: the only content is this one submessage, so the encode below stands or falls with it. + meshtastic_ModuleConfig mc = meshtastic_ModuleConfig_init_zero; + mc.which_payload_variant = tag; + TEST_ASSERT_TRUE_MESSAGE(admin->handleSetModuleConfig(mc), describeTag("setter rejected", tag)); + + // Save: presence flag unset means nanopb drops the struct and the write encodes to nothing. + uint8_t saved[meshtastic_LocalModuleConfig_size]; + size_t savedLen = pb_encode_to_bytes(saved, sizeof(saved), &meshtastic_LocalModuleConfig_msg, &moduleConfig); + TEST_ASSERT_GREATER_THAN_size_t_MESSAGE(0, savedLen, describeTag("dropped on save, presence flag not set", tag)); + + // Load: overwrite the live global, as a reboot would. + moduleConfig = meshtastic_LocalModuleConfig_init_zero; + TEST_ASSERT_TRUE_MESSAGE(pb_decode_from_bytes(saved, savedLen, &meshtastic_LocalModuleConfig_msg, &moduleConfig), + describeTag("failed to decode", tag)); + + // Re-encoding what was loaded must reproduce the saved bytes - presence survived the reload. + uint8_t reloaded[meshtastic_LocalModuleConfig_size]; + size_t reloadedLen = pb_encode_to_bytes(reloaded, sizeof(reloaded), &meshtastic_LocalModuleConfig_msg, &moduleConfig); + TEST_ASSERT_EQUAL_size_t_MESSAGE(savedLen, reloadedLen, describeTag("changed size across reload", tag)); + TEST_ASSERT_EQUAL_MEMORY_MESSAGE(saved, reloaded, savedLen, describeTag("changed content across reload", tag)); + + // Get: remote and local requests take the same branch and must answer with this submessage. + const NodeNum requesters[] = {ADMIN_NODE, 0}; + for (NodeNum from : requesters) { + meshtastic_MeshPacket req = makeGetRequest(from); + admin->handleGetModuleConfig(req, tag - 1); // type pairs 1:1 with tag (guard test above) + + pb_size_t responseTag = 0; + bool decoded = decodeResponseTagFromReply(admin->reply(), responseTag); + admin->drainReply(); + + TEST_ASSERT_TRUE_MESSAGE(decoded, describeTag("no get response", tag)); + TEST_ASSERT_EQUAL_MESSAGE(tag, responseTag, describeTag("get answered with a different submessage", tag)); + } + } +} + +// Each set is a single-submessage edit: writing type N must not erase types 1..N-1. Every additional +// stored submessage grows the encoded config, so a clobbered earlier write shows up as a non-increase. +void test_sequentialSets_preserveEarlierWrites(void) +{ + size_t prevLen = 0; + for (pb_size_t tag = 1; tag <= moduleConfigTypeCount; tag++) { + if (moduleConfigIsCompiledOut(tag)) + continue; + + meshtastic_ModuleConfig mc = meshtastic_ModuleConfig_init_zero; + mc.which_payload_variant = tag; + admin->handleSetModuleConfig(mc); + + uint8_t buf[meshtastic_LocalModuleConfig_size]; + size_t len = pb_encode_to_bytes(buf, sizeof(buf), &meshtastic_LocalModuleConfig_msg, &moduleConfig); + TEST_ASSERT_GREATER_THAN_size_t_MESSAGE(prevLen, len, describeTag("set erased an earlier submessage", tag)); + prevLen = len; + } +} + +void setUp(void) +{ + mockService = new MockMeshService(); + service = mockService; + + admin = new AdminModuleTestShim(); + admin->deferSaves(); // no disk write or reboot when a setter is accepted + + moduleConfig = meshtastic_LocalModuleConfig_init_zero; + config = meshtastic_LocalConfig_init_zero; + myNodeInfo = meshtastic_MyNodeInfo_init_zero; + myNodeInfo.my_node_num = LOCAL_NODE; +} + +void tearDown(void) +{ + admin->drainReply(); + delete admin; + admin = nullptr; + + service = nullptr; + delete mockService; + mockService = nullptr; +} + +void setup() +{ + delay(10); + initializeTestEnvironment(); + UNITY_BEGIN(); + + RUN_TEST(test_tagRangeMatchesTypeRange); + RUN_TEST(test_everyModuleConfig_survivesSetSaveLoadGet); + RUN_TEST(test_sequentialSets_preserveEarlierWrites); + + exit(UNITY_END()); +} + +void loop() {} diff --git a/test/test_mqtt/MQTT.cpp b/test/test_mqtt/MQTT.cpp index 3716e0a20..ab9a64e44 100644 --- a/test/test_mqtt/MQTT.cpp +++ b/test/test_mqtt/MQTT.cpp @@ -53,6 +53,15 @@ class MockRouter : public Router class MockMeshService : public MeshService { public: + // No PhoneAPI reader exists in these tests, so packets the receive pipeline forwards to the phone + // (MeshService::sendToPhone enqueues pooled copies into toPhoneQueue) would leak at teardown. Drain + // the queue like the phone would. This surfaced once sendLocal() began dispatching local packets + // through handleReceived() directly rather than via the (mock-overridden) enqueueReceivedMessage(). + ~MockMeshService() + { + while (meshtastic_MeshPacket *p = getForPhone()) + releaseToPool(p); + } void sendMqttMessageToClientProxy(meshtastic_MqttClientProxyMessage *m) override { messages_.emplace_back(*m); @@ -334,6 +343,7 @@ const meshtastic_MeshPacket encrypted = { // Initialize mocks and configuration before running each test. void setUp(void) { + config = meshtastic_LocalConfig_init_zero; moduleConfig.mqtt = meshtastic_ModuleConfig_MQTTConfig{.enabled = true, .map_reporting_enabled = true, .has_map_report_settings = true}; moduleConfig.mqtt.map_report_settings = meshtastic_ModuleConfig_MapReportSettings{ @@ -583,6 +593,37 @@ void test_receiveEmptyDataFromProxy(void) TEST_ASSERT_TRUE(mockRouter->packets_.empty()); } +// Text must be read as text: data.size aliases the string's first bytes, so reading it regardless +// of the variant let a client name a length of up to PB_SIZE_MAX. There is no delivery control for +// this variant: an encoded ServiceEnvelope always contains NUL, so text can never carry one. +void test_receiveTextVariantFromProxyIsNotReadAsBytes(void) +{ + meshtastic_MqttClientProxyMessage message = meshtastic_MqttClientProxyMessage_init_default; + snprintf(message.topic, sizeof(message.topic), "msh/2/e/test/!87654321"); + message.which_payload_variant = meshtastic_MqttClientProxyMessage_text_tag; + // data.size would read these as the largest length a pb_size_t can name. + memset(message.payload_variant.text, 0xFF, sizeof(message.payload_variant.text) - 1); + message.payload_variant.text[sizeof(message.payload_variant.text) - 1] = '\0'; + + mqtt->onClientProxyReceive(message); + + TEST_ASSERT_TRUE(mockRouter->packets_.empty()); +} + +// A proxy message with no payload variant set must be ignored rather than read as bytes. +void test_receiveNoVariantFromProxyIsIgnored(void) +{ + meshtastic_MqttClientProxyMessage message = meshtastic_MqttClientProxyMessage_init_default; + snprintf(message.topic, sizeof(message.topic), "msh/2/e/test/!87654321"); + message.which_payload_variant = 0; + memset(message.payload_variant.data.bytes, 0xFF, sizeof(message.payload_variant.data.bytes)); + message.payload_variant.data.size = sizeof(message.payload_variant.data.bytes); + + mqtt->onClientProxyReceive(message); + + TEST_ASSERT_TRUE(mockRouter->packets_.empty()); +} + // Packets should be ignored if downlink is not enabled. void test_receiveWithoutChannelDownlink(void) { @@ -596,6 +637,8 @@ void test_receiveWithoutChannelDownlink(void) // Test receiving an encrypted MeshPacket on the PKI topic. void test_receiveEncryptedPKITopicToUs(void) { + config.security.packet_signature_policy = + meshtastic_Config_SecurityConfig_PacketSignaturePolicy_PACKET_SIGNATURE_POLICY_STRICT; meshtastic_MeshPacket e = encrypted; e.to = myNodeInfo.my_node_num; @@ -687,6 +730,8 @@ static meshtastic_MeshPacket makeDecodedBroadcast() // signing node (audit F3). void test_receiveDropsUnsignedBroadcastFromSigner(void) { + config.security.packet_signature_policy = + meshtastic_Config_SecurityConfig_PacketSignaturePolicy_PACKET_SIGNATURE_POLICY_BALANCED; mockNodeDB->emptyNode.bitfield |= NODEINFO_BITFIELD_HAS_XEDDSA_SIGNED_MASK; const meshtastic_MeshPacket p = makeDecodedBroadcast(); @@ -698,6 +743,8 @@ void test_receiveDropsUnsignedBroadcastFromSigner(void) // The same unsigned broadcast from a node never seen signing is accepted. void test_receiveAcceptsUnsignedBroadcastFromNonSigner(void) { + config.security.packet_signature_policy = + meshtastic_Config_SecurityConfig_PacketSignaturePolicy_PACKET_SIGNATURE_POLICY_BALANCED; const meshtastic_MeshPacket p = makeDecodedBroadcast(); unitTest->publish(&p); @@ -729,6 +776,8 @@ void test_receiveVerifiesSignedDecodedDownlink(void) // A decoded downlink carrying a signature that fails verification is dropped. void test_receiveDropsBadSignatureOnDecodedDownlink(void) { + config.security.packet_signature_policy = + meshtastic_Config_SecurityConfig_PacketSignaturePolicy_PACKET_SIGNATURE_POLICY_COMPATIBLE; uint8_t pub[32], priv[32]; crypto->generateKeyPair(pub, priv); mockNodeDB->emptyNode.public_key.size = 32; @@ -744,6 +793,53 @@ void test_receiveDropsBadSignatureOnDecodedDownlink(void) TEST_ASSERT_TRUE(mockRouter->packets_.empty()); } + +void test_receiveCompatibleAcceptsUnsignedBroadcastFromSigner(void) +{ + config.security.packet_signature_policy = + meshtastic_Config_SecurityConfig_PacketSignaturePolicy_PACKET_SIGNATURE_POLICY_COMPATIBLE; + mockNodeDB->emptyNode.bitfield |= NODEINFO_BITFIELD_HAS_XEDDSA_SIGNED_MASK; + + const meshtastic_MeshPacket p = makeDecodedBroadcast(); + unitTest->publish(&p); + + TEST_ASSERT_EQUAL(1, mockRouter->packets_.size()); +} + +void test_receiveStrictDropsUnsignedPortnumsAndUnicast(void) +{ + config.security.packet_signature_policy = + meshtastic_Config_SecurityConfig_PacketSignaturePolicy_PACKET_SIGNATURE_POLICY_STRICT; + const meshtastic_PortNum ports[] = { + meshtastic_PortNum_TEXT_MESSAGE_APP, meshtastic_PortNum_POSITION_APP, meshtastic_PortNum_TELEMETRY_APP, + meshtastic_PortNum_NODEINFO_APP, meshtastic_PortNum_WAYPOINT_APP, + }; + for (const auto port : ports) { + meshtastic_MeshPacket p = makeDecodedBroadcast(); + p.decoded.portnum = port; + unitTest->publish(&p); + } + + meshtastic_MeshPacket unicast = makeDecodedBroadcast(); + unicast.to = myNodeInfo.my_node_num; + unicast.decoded.portnum = meshtastic_PortNum_POSITION_APP; + unitTest->publish(&unicast); + + TEST_ASSERT_TRUE(mockRouter->packets_.empty()); +} + +// A plaintext broker assertion is not evidence that AES-CCM authentication succeeded locally. +void test_receiveStrictDoesNotTrustDecodedPkiFlag(void) +{ + config.security.packet_signature_policy = + meshtastic_Config_SecurityConfig_PacketSignaturePolicy_PACKET_SIGNATURE_POLICY_STRICT; + meshtastic_MeshPacket p = makeDecodedBroadcast(); + p.to = myNodeInfo.my_node_num; + p.pki_encrypted = true; + unitTest->publish(&p); + + TEST_ASSERT_TRUE(mockRouter->packets_.empty()); +} #endif // !(MESHTASTIC_EXCLUDE_PKI) && !(MESHTASTIC_EXCLUDE_XEDDSA) // Only the same fields that are transmitted over LoRa should be set in MQTT messages. @@ -1051,6 +1147,8 @@ void setup() RUN_TEST(test_receiveDecodedProto); RUN_TEST(test_receiveDecodedProtoFromProxy); RUN_TEST(test_receiveEmptyDataFromProxy); + RUN_TEST(test_receiveTextVariantFromProxyIsNotReadAsBytes); + RUN_TEST(test_receiveNoVariantFromProxyIsIgnored); RUN_TEST(test_receiveWithoutChannelDownlink); RUN_TEST(test_receiveEncryptedPKITopicToUs); RUN_TEST(test_receiveIgnoresOwnPublishedMessages); @@ -1063,6 +1161,9 @@ void setup() RUN_TEST(test_receiveAcceptsUnsignedBroadcastFromNonSigner); RUN_TEST(test_receiveVerifiesSignedDecodedDownlink); RUN_TEST(test_receiveDropsBadSignatureOnDecodedDownlink); + RUN_TEST(test_receiveCompatibleAcceptsUnsignedBroadcastFromSigner); + RUN_TEST(test_receiveStrictDropsUnsignedPortnumsAndUnicast); + RUN_TEST(test_receiveStrictDoesNotTrustDecodedPkiFlag); #endif RUN_TEST(test_receiveIgnoresUnexpectedFields); RUN_TEST(test_receiveIgnoresInvalidHopLimit); diff --git a/test/test_nexthop_routing/test_main.cpp b/test/test_nexthop_routing/test_main.cpp index b3964eb25..c7a27bf5c 100644 --- a/test/test_nexthop_routing/test_main.cpp +++ b/test/test_nexthop_routing/test_main.cpp @@ -15,6 +15,7 @@ #include "gps/RTC.h" #include "mesh/NextHopRouter.h" #include "mesh/NodeDB.h" +#include "mesh/RadioInterface.h" #include #include #include @@ -87,6 +88,7 @@ class NextHopRouterTestShim : public NextHopRouter using NextHopRouter::noteRouteFailure; using NextHopRouter::noteRouteLearned; using NextHopRouter::noteRouteSuccess; + using NextHopRouter::perhapsRebroadcast; using Router::shouldDecrementHopLimit; // protected in Router void resetRouteHealthForTest() @@ -96,6 +98,34 @@ class NextHopRouterTestShim : public NextHopRouter } }; +// --------------------------------------------------------------------------- +// MockRadioInterface - mirrors RadioLibInterface::send()'s NODENUM_BROADCAST_NO_LORA branch, which +// returns ERRNO_SHOULD_RELEASE without releasing. +// --------------------------------------------------------------------------- +class MockRadioInterface : public RadioInterface +{ + public: + ErrorCode send(meshtastic_MeshPacket *p) override + { + sendCount++; + if (declineAll || p->to == NODENUM_BROADCAST_NO_LORA) + return ERRNO_SHOULD_RELEASE; + + packetPool.release(p); + return ERRNO_OK; + } + + uint32_t getPacketTime(uint32_t totalPacketLen, bool received = false) override + { + (void)totalPacketLen; + (void)received; + return 0; + } + + int sendCount = 0; + bool declineAll = false; +}; + static MockNodeDB *mockNodeDB = nullptr; static NextHopRouterTestShim *shim = nullptr; @@ -409,6 +439,65 @@ void test_hoplimit_decrement_when_resolved_not_favorite(void) TEST_ASSERT_TRUE(shim->shouldDecrementHopLimit(&p)); // unique but not a favorite -> decrement } +// =========================================================================== +// Rebroadcast of NODENUM_BROADCAST_NO_LORA +// =========================================================================== + +static MockRadioInterface *installMockIface() +{ + MockRadioInterface *m = new MockRadioInterface(); + shim->addInterface(std::unique_ptr(m)); + return m; +} + +// Eligible for rebroadcast: not from/to us, hops left, nonzero id, no next-hop preference. +// Encrypted variant so Router::send() skips the encode path. +static meshtastic_MeshPacket makeRebroadcastCandidate(NodeNum to) +{ + meshtastic_MeshPacket p = meshtastic_MeshPacket_init_zero; + p.from = 0x22222222; // not us + p.to = to; + p.id = 0x0BADF00D; + p.hop_start = 3; + p.hop_limit = 3; + p.next_hop = NO_NEXT_HOP_PREFERENCE; + p.which_payload_variant = meshtastic_MeshPacket_encrypted_tag; + p.encrypted.size = 8; + return p; +} + +// Control: proves the NO_LORA case below turns on the `to` field alone. +void test_rebroadcast_normal_broadcast_is_relayed(void) +{ + MockRadioInterface *mockIface = installMockIface(); + meshtastic_MeshPacket p = makeRebroadcastCandidate(NODENUM_BROADCAST); + + TEST_ASSERT_TRUE_MESSAGE(shim->perhapsRebroadcast(&p), "ordinary broadcast must be rebroadcast"); + TEST_ASSERT_EQUAL_MESSAGE(1, mockIface->sendCount, "exactly one packet should reach the radio"); +} + +void test_rebroadcast_no_lora_broadcast_is_not_relayed(void) +{ + MockRadioInterface *mockIface = installMockIface(); + meshtastic_MeshPacket p = makeRebroadcastCandidate(NODENUM_BROADCAST_NO_LORA); + + TEST_ASSERT_FALSE_MESSAGE(shim->perhapsRebroadcast(&p), "no-LoRa broadcast must not be rebroadcast"); + TEST_ASSERT_EQUAL_MESSAGE(0, mockIface->sendCount, "no packet should be handed to the radio at all"); +} + +// Declining mock bypasses the guard so send() is reached; the release itself is only observable as +// a sanitizer leak report, not an assertion. +void test_rebroadcast_declined_send_releases_packet(void) +{ + MockRadioInterface *mockIface = installMockIface(); + mockIface->declineAll = true; + + meshtastic_MeshPacket p = makeRebroadcastCandidate(NODENUM_BROADCAST); + + TEST_ASSERT_TRUE_MESSAGE(shim->perhapsRebroadcast(&p), "the rebroadcast must still be attempted"); + TEST_ASSERT_EQUAL_MESSAGE(1, mockIface->sendCount, "the copy must have reached the mock radio"); +} + // =========================================================================== void setup() @@ -459,6 +548,11 @@ void setup() RUN_TEST(test_hoplimit_decrement_on_colliding_favorites); RUN_TEST(test_hoplimit_decrement_when_resolved_not_favorite); + printf("\n=== rebroadcast of NODENUM_BROADCAST_NO_LORA ===\n"); + RUN_TEST(test_rebroadcast_normal_broadcast_is_relayed); + RUN_TEST(test_rebroadcast_no_lora_broadcast_is_not_relayed); + RUN_TEST(test_rebroadcast_declined_send_releases_packet); + exit(UNITY_END()); } diff --git a/test/test_packet_signing/test_main.cpp b/test/test_packet_signing/test_main.cpp index f0eb60473..f58c585b3 100644 --- a/test/test_packet_signing/test_main.cpp +++ b/test/test_packet_signing/test_main.cpp @@ -3,16 +3,19 @@ // // The decision logic under test lives in Router.cpp free functions. Groups A/B drive a real // encode -> decode round-trip through the default channel (perhapsEncode/perhapsDecode, black-box, -// no production changes); Groups C-E exercise the policy helpers directly. +// no production changes); later groups exercise routing order and policy helpers directly. // // Group A receive-side accept/reject matrix (verify, downgrade protection, signer-bit learning) // Group B send-side signing policy (which outgoing packets perhapsEncode signs) -// Group C NodeInfoModule's broadcast-only "drop unsigned NodeInfo from a known signer" rule +// Group C routing pipeline ordering (authenticate before duplicate/retry/relay state) // Group D encoding invariants the routing gates depend on // Group E decoded-ingress policy (checkXeddsaReceivePolicy, the plaintext-MQTT trust boundary) #include "MeshTypes.h" // include BEFORE TestUtil.h +#include "NodeStatus.h" #include "TestUtil.h" +#include "airtime.h" +#include "support/MockMeshService.h" #include // The whole suite exercises XEdDSA sign/verify and checkXeddsaReceivePolicy, all of which are @@ -21,12 +24,20 @@ #include "mesh/Channels.h" #include "mesh/CryptoEngine.h" +#include "mesh/MeshRadio.h" +#include "mesh/MeshService.h" #include "mesh/NodeDB.h" +#include "mesh/ReliableRouter.h" #include "mesh/Router.h" +#include "mesh/SinglePortModule.h" #include "modules/NodeInfoModule.h" +#include "modules/RoutingModule.h" +#include "mqtt/MQTT.h" +#include #include #include #include +#include #include #include @@ -48,6 +59,8 @@ static constexpr size_t OVERSIZED_PAYLOAD = 180; class MockNodeDB : public NodeDB { public: + void installDefaultsPreservingIdentity() { installDefaultConfig(true); } + void clearTestNodes() { testNodes.clear(); @@ -81,11 +94,126 @@ class MockNodeDB : public NodeDB nodeInfoLiteSetBit(n, NODEINFO_BITFIELD_HAS_XEDDSA_SIGNED_MASK, value); } + void setLongName(NodeNum num, const char *name) + { + meshtastic_NodeInfoLite *n = getMeshNode(num); + TEST_ASSERT_NOT_NULL(n); + strncpy(n->long_name, name, sizeof(n->long_name) - 1); + n->long_name[sizeof(n->long_name) - 1] = '\0'; + } + + const char *longName(NodeNum num) + { + meshtastic_NodeInfoLite *n = getMeshNode(num); + TEST_ASSERT_NOT_NULL(n); + return n->long_name; + } + std::vector testNodes; }; static MockNodeDB *mockNodeDB = nullptr; +class AuthPipelineRadio : public RadioInterface +{ + public: + ErrorCode send(meshtastic_MeshPacket *p) override + { + sendCalls++; + packetPool.release(p); + return ERRNO_OK; + } + bool cancelSending(NodeNum, PacketId) override + { + cancelCalls++; + return true; + } + bool findInTxQueue(NodeNum, PacketId) override + { + findCalls++; + return false; + } + bool removePendingTXPacket(NodeNum, PacketId, uint32_t) override + { + removeCalls++; + return true; + } + uint32_t getPacketTime(uint32_t, bool = false) override { return 7; } + void reset() { sendCalls = cancelCalls = findCalls = removeCalls = 0; } + + uint32_t sendCalls = 0; + uint32_t cancelCalls = 0; + uint32_t findCalls = 0; + uint32_t removeCalls = 0; +}; + +class AuthPipelineRouter : public ReliableRouter +{ + public: + bool filter(meshtastic_MeshPacket *p) { return ReliableRouter::shouldFilterReceived(p); } + bool historyContains(const meshtastic_MeshPacket *p) { return wasSeenRecently(p, false); } + void remember(const meshtastic_MeshPacket *p) { wasSeenRecently(p, true); } + void forgetRelayer(uint8_t relay, PacketId id, NodeNum from) { removeRelayer(relay, id, from); } + bool handleUpgrade(meshtastic_MeshPacket *p) { return perhapsHandleUpgradedPacket(p); } + void addPending(const meshtastic_MeshPacket &p, uint32_t nextTx) + { + auto *copy = packetPool.allocCopy(p); + TEST_ASSERT_NOT_NULL(copy); + const GlobalPacketId key(copy); + pending.emplace(key, PendingPacket(copy, NUM_INTERMEDIATE_RETX)); + pending.at(key).nextTxMsec = nextTx; + } + uint32_t pendingNextTx(NodeNum from, PacketId id) + { + PendingPacket *entry = findPendingPacket(from, id); + return entry ? entry->nextTxMsec : 0; + } + size_t pendingCount() const { return pending.size(); } + void clearPending() + { + for (auto &entry : pending) + packetPool.release(entry.second.packet); + pending.clear(); + } +}; + +class AuthPipelineRoutingModule : public RoutingModule +{ + public: + void sendAckNak(meshtastic_Routing_Error, NodeNum, PacketId, ChannelIndex, uint8_t = 0, bool = false) override { ackCalls++; } + uint32_t ackCalls = 0; +}; + +class AuthPipelineModule : public SinglePortModule +{ + public: + AuthPipelineModule() : SinglePortModule("authPipeline", meshtastic_PortNum_POSITION_APP) {} + ProcessMessage handleReceived(const meshtastic_MeshPacket &) override + { + calls++; + return ProcessMessage::CONTINUE; + } + uint32_t calls = 0; +}; + +class AuthPipelineMqtt : public MQTT +{ + public: + int queueSize() { return mqttQueue.numUsed(); } + void clearQueue() + { + while (QueueEntry *entry = mqttQueue.dequeuePtr(0)) + delete entry; + } +}; + +static AuthPipelineRouter *pipelineRouter = nullptr; +static AuthPipelineRadio *pipelineRadio = nullptr; +static AuthPipelineRoutingModule *pipelineRouting = nullptr; +static AuthPipelineModule *pipelineModule = nullptr; +static AuthPipelineMqtt *pipelineMqtt = nullptr; +static MeshService *pipelineService = nullptr; + // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- @@ -126,11 +254,48 @@ static DecodeState roundTrip(meshtastic_MeshPacket *p) return perhapsDecode(p); } +static meshtastic_MeshPacket channelEncode(meshtastic_MeshPacket p) +{ + uint8_t encoded[MAX_LORA_PAYLOAD_LEN + 1] = {}; + const size_t encodedSize = pb_encode_to_bytes(encoded, sizeof(encoded), &meshtastic_Data_msg, &p.decoded); + TEST_ASSERT_GREATER_THAN(0, encodedSize); + const int16_t hash = channels.setActiveByIndex(p.channel); + TEST_ASSERT_GREATER_OR_EQUAL(0, hash); + crypto->encryptPacket(p.from, p.id, encodedSize, encoded); + memcpy(p.encrypted.bytes, encoded, encodedSize); + p.encrypted.size = encodedSize; + p.channel = hash; + p.which_payload_variant = meshtastic_MeshPacket_encrypted_tag; + return p; +} + +static meshtastic_MeshPacket makeSignedWirePacket(NodeNum from, NodeNum to, PacketId id, uint8_t hopLimit = 1, + uint8_t hopStart = 2, uint8_t nextHop = NO_NEXT_HOP_PREFERENCE, + uint8_t relayNode = 0x33, bool valid = true) +{ + meshtastic_MeshPacket p = makeDecoded(from, to, meshtastic_PortNum_POSITION_APP, SMALL_PAYLOAD); + p.id = id; + p.hop_limit = hopLimit; + p.hop_start = hopStart; + p.next_hop = nextHop; + p.relay_node = relayNode; + p.transport_mechanism = meshtastic_MeshPacket_TransportMechanism_TRANSPORT_LORA; + signWithCurrentKey(&p); + if (!valid) + p.decoded.xeddsa_signature.bytes[0] ^= 0x80; + return channelEncode(p); +} + static bool remoteSignerBit() { return nodeInfoLiteHasXeddsaSigned(mockNodeDB->getMeshNode(REMOTE_NODE)); } +static void setPolicy(meshtastic_Config_SecurityConfig_PacketSignaturePolicy policy) +{ + config.security.packet_signature_policy = policy; +} + // Size a Data message exactly as the wire encoder would. static size_t encodedDataSize(const meshtastic_Data *d) { @@ -205,22 +370,43 @@ static meshtastic_MeshPacket makeBroadcastWithUnknownFields() // --------------------------------------------------------------------------- void setUp(void) { + service = pipelineService; + // Construct the mock FIRST: the NodeDB constructor can reload persisted state from the // host filesystem (portduino VFS) and repopulate the globals - a saved private key // re-enables the PKI encrypt path and fails the unicast tests on hosts with leftover prefs. mockNodeDB = new MockNodeDB(); mockNodeDB->clearTestNodes(); +#if WARM_NODE_COUNT > 0 + mockNodeDB->warmStore.clear(); +#endif nodeDB = mockNodeDB; // Clean global config/owner AFTER the ctor; zeroed config => rebroadcast ALL (no KNOWN_ONLY // drop) and security.private_key.size == 0 (PKI encrypt path skipped => simple channel crypto). config = meshtastic_LocalConfig_init_zero; + moduleConfig = meshtastic_LocalModuleConfig_init_zero; owner = meshtastic_User_init_zero; + // Exercise the downgrade-protection matrix by default. Production defaults to + // COMPATIBLE so existing meshes remain interoperable; tests that cover that + // mode opt in explicitly. + setPolicy(meshtastic_Config_SecurityConfig_PacketSignaturePolicy_PACKET_SIGNATURE_POLICY_BALANCED); myNodeInfo.my_node_num = LOCAL_NODE; // drives isFromUs()/getFrom()/isToUs() // Working primary channel with the default PSK so encrypt/decrypt round-trips. channels.initDefaults(); channels.onConfigChanged(); + + pipelineRouter->clearPending(); + pipelineRouter->rxDupe = 0; + pipelineRouter->txRelayCanceled = 0; + pipelineRadio->reset(); + pipelineRouting->ackCalls = 0; + pipelineModule->calls = 0; + pipelineMqtt->clearQueue(); + while (meshtastic_MeshPacket *queued = pipelineService->getForPhone()) + packetPool.release(queued); + resetRoutingAuthEvaluationCount(); } void tearDown(void) @@ -237,6 +423,7 @@ void tearDown(void) // A1: valid signature from a node whose key we know -> accepted, marked signed, signer bit learned. void test_A1_valid_signature_accepted_and_learns_signer(void) { + setPolicy(meshtastic_Config_SecurityConfig_PacketSignaturePolicy_PACKET_SIGNATURE_POLICY_STRICT); uint8_t pub[32], priv[32]; crypto->generateKeyPair(pub, priv); // engine now holds REMOTE's key mockNodeDB->addNode(REMOTE_NODE); @@ -255,6 +442,7 @@ void test_A1_valid_signature_accepted_and_learns_signer(void) // A2: a tampered signature from a known key -> dropped. void test_A2_bad_signature_dropped(void) { + setPolicy(meshtastic_Config_SecurityConfig_PacketSignaturePolicy_PACKET_SIGNATURE_POLICY_COMPATIBLE); uint8_t pub[32], priv[32]; crypto->generateKeyPair(pub, priv); mockNodeDB->addNode(REMOTE_NODE); @@ -264,12 +452,13 @@ void test_A2_bad_signature_dropped(void) signWithCurrentKey(&p); p.decoded.xeddsa_signature.bytes[0] ^= 0xFF; // corrupt the signature - TEST_ASSERT_EQUAL(DECODE_FAILURE, roundTrip(&p)); + TEST_ASSERT_EQUAL(DECODE_POLICY_REJECT, roundTrip(&p)); } // A3: signed packet but we have no key for the sender -> accepted unverified, signer bit NOT set. void test_A3_signed_no_pubkey_accepted_unverified(void) { + setPolicy(meshtastic_Config_SecurityConfig_PacketSignaturePolicy_PACKET_SIGNATURE_POLICY_BALANCED); uint8_t pub[32], priv[32]; crypto->generateKeyPair(pub, priv); mockNodeDB->addNode(REMOTE_NODE); // node exists, but no public key stored @@ -291,7 +480,7 @@ void test_A4_downgrade_unsigned_broadcast_from_signer_dropped(void) meshtastic_MeshPacket p = makeDecoded(REMOTE_NODE, NODENUM_BROADCAST, meshtastic_PortNum_TEXT_MESSAGE_APP, SMALL_PAYLOAD); // from != us, so perhapsEncode leaves it unsigned. - TEST_ASSERT_EQUAL(DECODE_FAILURE, roundTrip(&p)); + TEST_ASSERT_EQUAL(DECODE_POLICY_REJECT, roundTrip(&p)); } // A5: no prior knowledge - unsigned small broadcast from a non-signer -> accepted. @@ -373,40 +562,217 @@ void test_A9_unsigned_boundary_broadcast_from_signer_still_dropped(void) TEST_ASSERT_EQUAL_MESSAGE(MAX_LORA_PAYLOAD_LEN, encodedDataSize(&signedCopy) + MESHTASTIC_HEADER_LENGTH, "payload no longer sits exactly on the fit boundary - retune it"); - TEST_ASSERT_EQUAL(DECODE_FAILURE, roundTrip(&p)); + TEST_ASSERT_EQUAL(DECODE_POLICY_REJECT, roundTrip(&p)); } -// A10: unknown fields must not sway the downgrade decision. A sender on a newer schema can include -// Data fields this build doesn't define; nanopb skips them, so they grow the frame without changing -// what decodes. The decision sizes p->decoded, keeping it on the fields the sender encoded, so an -// unsigned broadcast from a known signer is dropped whether or not unknown fields came along. -void test_A10_unsigned_broadcast_from_signer_with_unknown_fields_dropped(void) +void test_A10_compatible_accepts_unsigned_broadcast_from_signer(void) +{ + setPolicy(meshtastic_Config_SecurityConfig_PacketSignaturePolicy_PACKET_SIGNATURE_POLICY_COMPATIBLE); + mockNodeDB->addNode(REMOTE_NODE); + mockNodeDB->setSignerBit(REMOTE_NODE, true); + + meshtastic_MeshPacket p = makeDecoded(REMOTE_NODE, NODENUM_BROADCAST, meshtastic_PortNum_POSITION_APP, SMALL_PAYLOAD); + TEST_ASSERT_EQUAL(DECODE_SUCCESS, roundTrip(&p)); +} + +void test_A11_strict_rejects_unsigned_all_portnums_destinations_and_sizes(void) +{ + setPolicy(meshtastic_Config_SecurityConfig_PacketSignaturePolicy_PACKET_SIGNATURE_POLICY_STRICT); + const meshtastic_PortNum ports[] = { + meshtastic_PortNum_TEXT_MESSAGE_APP, meshtastic_PortNum_POSITION_APP, meshtastic_PortNum_TELEMETRY_APP, + meshtastic_PortNum_NODEINFO_APP, meshtastic_PortNum_WAYPOINT_APP, + }; + for (const auto port : ports) { + meshtastic_MeshPacket p = makeDecoded(REMOTE_NODE, NODENUM_BROADCAST, port, SMALL_PAYLOAD); + TEST_ASSERT_EQUAL(DECODE_POLICY_REJECT, roundTrip(&p)); + } + + meshtastic_MeshPacket unicast = makeDecoded(REMOTE_NODE, LOCAL_NODE, meshtastic_PortNum_POSITION_APP, SMALL_PAYLOAD); + TEST_ASSERT_EQUAL(DECODE_POLICY_REJECT, roundTrip(&unicast)); + + meshtastic_MeshPacket oversized = + makeDecoded(REMOTE_NODE, NODENUM_BROADCAST, meshtastic_PortNum_POSITION_APP, OVERSIZED_PAYLOAD); + TEST_ASSERT_EQUAL(DECODE_POLICY_REJECT, roundTrip(&oversized)); +} + +void test_A12_strict_rejects_signed_packet_without_key(void) +{ + setPolicy(meshtastic_Config_SecurityConfig_PacketSignaturePolicy_PACKET_SIGNATURE_POLICY_STRICT); + uint8_t pub[32], priv[32]; + crypto->generateKeyPair(pub, priv); + + meshtastic_MeshPacket p = makeDecoded(REMOTE_NODE, NODENUM_BROADCAST, meshtastic_PortNum_POSITION_APP, SMALL_PAYLOAD); + signWithCurrentKey(&p); + TEST_ASSERT_EQUAL(DECODE_POLICY_REJECT, roundTrip(&p)); +} + +void test_A13_strict_accepts_locally_authenticated_pki_packet(void) +{ + setPolicy(meshtastic_Config_SecurityConfig_PacketSignaturePolicy_PACKET_SIGNATURE_POLICY_STRICT); + uint8_t localPub[32], localPriv[32], remotePub[32], remotePriv[32]; + crypto->generateKeyPair(localPub, localPriv); + crypto->generateKeyPair(remotePub, remotePriv); + mockNodeDB->addNode(LOCAL_NODE); + mockNodeDB->setPublicKey(LOCAL_NODE, localPub); + mockNodeDB->addNode(REMOTE_NODE); + mockNodeDB->setPublicKey(REMOTE_NODE, remotePub); + + meshtastic_Data data = meshtastic_Data_init_zero; + data.portnum = meshtastic_PortNum_PRIVATE_APP; + data.payload.size = SMALL_PAYLOAD; + memset(data.payload.bytes, 0x5A, data.payload.size); + uint8_t plaintext[MAX_LORA_PAYLOAD_LEN + 1] = {}; + const size_t plaintextSize = pb_encode_to_bytes(plaintext, sizeof(plaintext), &meshtastic_Data_msg, &data); + TEST_ASSERT_GREATER_THAN(0, plaintextSize); + + meshtastic_NodeInfoLite_public_key_t localKey = {32, {0}}; + memcpy(localKey.bytes, localPub, sizeof(localPub)); + meshtastic_MeshPacket p = meshtastic_MeshPacket_init_zero; + p.from = REMOTE_NODE; + p.to = LOCAL_NODE; + p.id = 0x0CC01234; + p.channel = 0; + p.which_payload_variant = meshtastic_MeshPacket_encrypted_tag; + crypto->setDHPrivateKey(remotePriv); + TEST_ASSERT_TRUE(crypto->encryptCurve25519(p.to, p.from, localKey, p.id, plaintextSize, plaintext, p.encrypted.bytes)); + p.encrypted.size = plaintextSize + MESHTASTIC_PKC_OVERHEAD; + + // Only the receiver's private key can establish the local pki_encrypted authentication marker. + crypto->setDHPrivateKey(localPriv); + TEST_ASSERT_EQUAL(DECODE_SUCCESS, perhapsDecode(&p)); + TEST_ASSERT_TRUE(p.pki_encrypted); + TEST_ASSERT_EQUAL(meshtastic_PortNum_PRIVATE_APP, p.decoded.portnum); +} + +void test_A13b_strict_rejects_spoofed_pki_flag_on_encrypted_ingress(void) +{ + setPolicy(meshtastic_Config_SecurityConfig_PacketSignaturePolicy_PACKET_SIGNATURE_POLICY_STRICT); + meshtastic_MeshPacket p = makeDecoded(REMOTE_NODE, LOCAL_NODE, meshtastic_PortNum_POSITION_APP, SMALL_PAYLOAD); + TEST_ASSERT_EQUAL(meshtastic_Routing_Error_NONE, perhapsEncode(&p)); + p.pki_encrypted = true; + p.public_key.size = 32; + memset(p.public_key.bytes, 0xAB, p.public_key.size); + + TEST_ASSERT_EQUAL(DECODE_POLICY_REJECT, perhapsDecode(&p)); + TEST_ASSERT_FALSE(p.pki_encrypted); + TEST_ASSERT_EQUAL(0, p.public_key.size); +} + +void test_A14_strict_bootstraps_identity_bound_signed_nodeinfo(void) +{ + setPolicy(meshtastic_Config_SecurityConfig_PacketSignaturePolicy_PACKET_SIGNATURE_POLICY_STRICT); + uint8_t pub[32], priv[32]; + crypto->generateKeyPair(pub, priv); + const NodeNum signer = crc32Buffer(pub, sizeof(pub)); + + meshtastic_User user = meshtastic_User_init_zero; + user.public_key.size = sizeof(pub); + memcpy(user.public_key.bytes, pub, sizeof(pub)); + meshtastic_MeshPacket p = makeDecoded(signer, NODENUM_BROADCAST, meshtastic_PortNum_NODEINFO_APP, 0); + p.decoded.payload.size = + pb_encode_to_bytes(p.decoded.payload.bytes, sizeof(p.decoded.payload.bytes), &meshtastic_User_msg, &user); + signWithCurrentKey(&p); + + TEST_ASSERT_EQUAL(DECODE_SUCCESS, roundTrip(&p)); + const meshtastic_NodeInfoLite *node = mockNodeDB->getMeshNode(signer); + TEST_ASSERT_NOT_NULL(node); + TEST_ASSERT_EQUAL_UINT8_ARRAY(pub, node->public_key.bytes, sizeof(pub)); + TEST_ASSERT_TRUE(p.xeddsa_signed); +} + +void test_A15_strict_rejects_nodeinfo_key_without_identity_binding(void) +{ + setPolicy(meshtastic_Config_SecurityConfig_PacketSignaturePolicy_PACKET_SIGNATURE_POLICY_STRICT); + uint8_t pub[32], priv[32]; + crypto->generateKeyPair(pub, priv); + + meshtastic_User user = meshtastic_User_init_zero; + user.public_key.size = sizeof(pub); + memcpy(user.public_key.bytes, pub, sizeof(pub)); + meshtastic_MeshPacket p = + makeDecoded(crc32Buffer(pub, sizeof(pub)) ^ 1, NODENUM_BROADCAST, meshtastic_PortNum_NODEINFO_APP, 0); + p.decoded.payload.size = + pb_encode_to_bytes(p.decoded.payload.bytes, sizeof(p.decoded.payload.bytes), &meshtastic_User_msg, &user); + signWithCurrentKey(&p); + + TEST_ASSERT_EQUAL(DECODE_POLICY_REJECT, roundTrip(&p)); + TEST_ASSERT_NULL(mockNodeDB->getMeshNode(p.from)); +} + +void test_A16_compatible_rejects_invalid_first_contact_nodeinfo(void) +{ + setPolicy(meshtastic_Config_SecurityConfig_PacketSignaturePolicy_PACKET_SIGNATURE_POLICY_COMPATIBLE); + uint8_t pub[32], priv[32]; + crypto->generateKeyPair(pub, priv); + + meshtastic_User user = meshtastic_User_init_zero; + user.public_key.size = sizeof(pub); + memcpy(user.public_key.bytes, pub, sizeof(pub)); + meshtastic_MeshPacket p = + makeDecoded(crc32Buffer(pub, sizeof(pub)) ^ 1, NODENUM_BROADCAST, meshtastic_PortNum_NODEINFO_APP, 0); + p.decoded.payload.size = + pb_encode_to_bytes(p.decoded.payload.bytes, sizeof(p.decoded.payload.bytes), &meshtastic_User_msg, &user); + signWithCurrentKey(&p); + + TEST_ASSERT_EQUAL(DECODE_POLICY_REJECT, roundTrip(&p)); +} + +#if WARM_NODE_COUNT > 0 +void test_A17_strict_verifies_signer_from_warm_key_store(void) +{ + setPolicy(meshtastic_Config_SecurityConfig_PacketSignaturePolicy_PACKET_SIGNATURE_POLICY_STRICT); + uint8_t pub[32], priv[32]; + crypto->generateKeyPair(pub, priv); + TEST_ASSERT_TRUE(mockNodeDB->warmStore.absorb(REMOTE_NODE, 1, pub)); + TEST_ASSERT_NULL(mockNodeDB->getMeshNode(REMOTE_NODE)); + + meshtastic_MeshPacket p = makeDecoded(REMOTE_NODE, NODENUM_BROADCAST, meshtastic_PortNum_POSITION_APP, SMALL_PAYLOAD); + signWithCurrentKey(&p); + TEST_ASSERT_EQUAL(DECODE_SUCCESS, roundTrip(&p)); + TEST_ASSERT_TRUE(p.xeddsa_signed); + const meshtastic_NodeInfoLite *rehydrated = mockNodeDB->getMeshNode(REMOTE_NODE); + TEST_ASSERT_NOT_NULL_MESSAGE(rehydrated, "verified warm signer must be re-admitted to the hot store"); + TEST_ASSERT_EQUAL_UINT8_ARRAY(pub, rehydrated->public_key.bytes, sizeof(pub)); + TEST_ASSERT_TRUE_MESSAGE(nodeInfoLiteHasXeddsaSigned(rehydrated), "re-admitted signer must retain Balanced downgrade memory"); + + // Model its next hot-store eviction and prove Balanced still remembers the signer without + // allocating a hot node merely to evaluate an unsigned packet. + // Mirror what NodeDB eviction actually stores for a signer: warmProtectedCategory() yields + // XeddsaSigner *and* the dedicated warm signer bit is set from nodeInfoLiteHasXeddsaSigned(). + // isKnownXeddsaSigner() reads that signer bit, not the protected category. + TEST_ASSERT_TRUE(mockNodeDB->warmStore.absorb(REMOTE_NODE, 2, pub, meshtastic_Config_DeviceConfig_Role_CLIENT, + static_cast(WarmProtected::XeddsaSigner), /*signer=*/true)); + mockNodeDB->clearTestNodes(); + setPolicy(meshtastic_Config_SecurityConfig_PacketSignaturePolicy_PACKET_SIGNATURE_POLICY_BALANCED); + meshtastic_MeshPacket unsignedPacket = + makeDecoded(REMOTE_NODE, NODENUM_BROADCAST, meshtastic_PortNum_POSITION_APP, SMALL_PAYLOAD); + TEST_ASSERT_FALSE_MESSAGE(checkXeddsaReceivePolicy(&unsignedPacket), + "Balanced downgrade memory must survive repeated hot-store eviction"); +} +#endif + +void test_A18_unsigned_broadcast_from_signer_with_unknown_fields_dropped(void) { mockNodeDB->addNode(REMOTE_NODE); - mockNodeDB->setSignerBit(REMOTE_NODE, true); // we've seen this node sign before + mockNodeDB->setSignerBit(REMOTE_NODE, true); meshtastic_MeshPacket p = makeBroadcastWithUnknownFields(); - TEST_ASSERT_EQUAL_MESSAGE(DECODE_FAILURE, perhapsDecode(&p), + TEST_ASSERT_EQUAL_MESSAGE(DECODE_POLICY_REJECT, perhapsDecode(&p), "unsigned broadcast from a signer must be dropped despite unknown fields"); } -// A11: A10's control - the same frame from a node we've never seen sign is accepted and still -// delivers its payload, so A10's DECODE_FAILURE is the downgrade drop and not a decode failure -// caused by the unknown fields. -void test_A11_unsigned_broadcast_from_nonsigner_with_unknown_fields_accepted(void) +void test_A19_unsigned_broadcast_from_nonsigner_with_unknown_fields_accepted(void) { - mockNodeDB->addNode(REMOTE_NODE); // signer bit clear + mockNodeDB->addNode(REMOTE_NODE); meshtastic_MeshPacket p = makeBroadcastWithUnknownFields(); - const size_t rawSize = p.encrypted.size; // read before decode: encrypted/decoded share a union + const size_t rawSize = p.encrypted.size; TEST_ASSERT_EQUAL_MESSAGE(DECODE_SUCCESS, perhapsDecode(&p), "frame from a non-signer must still decode"); TEST_ASSERT_EQUAL_MESSAGE(meshtastic_PortNum_POSITION_APP, p.decoded.portnum, "unknown fields must not disturb the portnum"); TEST_ASSERT_EQUAL_MESSAGE(SMALL_PAYLOAD, p.decoded.payload.size, "payload must survive the unknown fields"); TEST_ASSERT_FALSE(p.xeddsa_signed); - - // The unknown fields are gone from the decoded struct: the gap the sizing basis has to account for. TEST_ASSERT_LESS_THAN_MESSAGE(rawSize, encodedDataSize(&p.decoded), "unknown fields must drop at decode, leaving decoded size < raw"); } @@ -430,15 +796,15 @@ void test_B1_local_broadcast_is_signed(void) TEST_ASSERT_TRUE(p.xeddsa_signed); } -// B2: our own unicast is NOT signed. +// B2: preserve the existing wire behavior: non-PKI unicast is not signed. void test_B2_local_unicast_not_signed(void) { mockNodeDB->addNode(REMOTE_NODE); - meshtastic_MeshPacket p = makeDecoded(LOCAL_NODE, REMOTE_NODE, meshtastic_PortNum_PRIVATE_APP, SMALL_PAYLOAD); + meshtastic_MeshPacket p = makeDecoded(LOCAL_NODE, REMOTE_NODE, meshtastic_PortNum_POSITION_APP, SMALL_PAYLOAD); TEST_ASSERT_EQUAL(DECODE_SUCCESS, roundTrip(&p)); - TEST_ASSERT_EQUAL_MESSAGE(0, p.decoded.xeddsa_signature.size, "unicast must not be signed"); + TEST_ASSERT_EQUAL_MESSAGE(0, p.decoded.xeddsa_signature.size, "unicast must remain unsigned"); } // B3: our own oversized broadcast is NOT signed (signature wouldn't fit). @@ -487,14 +853,12 @@ void test_B4_all_broadcast_sizes_deliverable_no_deadband(void) TEST_ASSERT_TRUE_MESSAGE(sawUnsigned, "sweep never crossed the fit boundary"); } -// B5: a client-preset signature on a packet we originate is discarded, not transmitted. -// perhapsEncode owns signing for our packets; a stale/garbage signature from a phone app on a -// packet we don't sign (here: unicast) would otherwise fail verification at every receiver. +// B5: a client-preset signature on a packet outside the existing broadcast sign class is discarded. void test_B5_preset_signature_on_local_packet_cleared(void) { mockNodeDB->addNode(REMOTE_NODE); - meshtastic_MeshPacket p = makeDecoded(LOCAL_NODE, REMOTE_NODE, meshtastic_PortNum_PRIVATE_APP, SMALL_PAYLOAD); + meshtastic_MeshPacket p = makeDecoded(LOCAL_NODE, REMOTE_NODE, meshtastic_PortNum_POSITION_APP, SMALL_PAYLOAD); p.decoded.xeddsa_signature.size = XEDDSA_SIGNATURE_SIZE; memset(p.decoded.xeddsa_signature.bytes, 0xAB, XEDDSA_SIGNATURE_SIZE); @@ -541,69 +905,217 @@ void test_B6_rich_shape_sweep_no_deadband(void) TEST_ASSERT_TRUE_MESSAGE(sawUnsigned, "rich sweep never crossed the fit boundary"); } +void test_B7_infrastructure_port_signing_matrix(void) +{ + uint8_t pub[32], priv[32]; + crypto->generateKeyPair(pub, priv); + mockNodeDB->addNode(LOCAL_NODE); + mockNodeDB->setPublicKey(LOCAL_NODE, pub); + + const meshtastic_PortNum ports[] = { + meshtastic_PortNum_NODEINFO_APP, + meshtastic_PortNum_ROUTING_APP, + meshtastic_PortNum_TRACEROUTE_APP, + meshtastic_PortNum_POSITION_APP, + }; + for (const auto port : ports) { + meshtastic_MeshPacket broadcast = makeDecoded(LOCAL_NODE, NODENUM_BROADCAST, port, SMALL_PAYLOAD); + TEST_ASSERT_EQUAL(DECODE_SUCCESS, roundTrip(&broadcast)); + TEST_ASSERT_EQUAL_MESSAGE(XEDDSA_SIGNATURE_SIZE, broadcast.decoded.xeddsa_signature.size, + "signable infrastructure broadcast must be signed"); + + meshtastic_MeshPacket unicast = makeDecoded(LOCAL_NODE, REMOTE_NODE, port, SMALL_PAYLOAD); + TEST_ASSERT_EQUAL(DECODE_SUCCESS, roundTrip(&unicast)); + TEST_ASSERT_EQUAL_MESSAGE(0, unicast.decoded.xeddsa_signature.size, + "infrastructure unicast must preserve existing unsigned behavior"); + } +} + +void test_B8_licensed_broadcast_and_unicast_are_signed(void) +{ + uint8_t pub[32], priv[32]; + crypto->generateKeyPair(pub, priv); + mockNodeDB->addNode(LOCAL_NODE); + mockNodeDB->setPublicKey(LOCAL_NODE, pub); + owner.is_licensed = true; + channels.ensureLicensedOperation(); + + meshtastic_MeshPacket broadcast = + makeDecoded(LOCAL_NODE, NODENUM_BROADCAST, meshtastic_PortNum_TEXT_MESSAGE_APP, SMALL_PAYLOAD); + TEST_ASSERT_EQUAL(DECODE_SUCCESS, roundTrip(&broadcast)); + TEST_ASSERT_EQUAL(XEDDSA_SIGNATURE_SIZE, broadcast.decoded.xeddsa_signature.size); + TEST_ASSERT_TRUE(broadcast.xeddsa_signed); + + meshtastic_MeshPacket direct = makeDecoded(LOCAL_NODE, REMOTE_NODE, meshtastic_PortNum_TEXT_MESSAGE_APP, SMALL_PAYLOAD); + TEST_ASSERT_EQUAL(DECODE_SUCCESS, roundTrip(&direct)); + TEST_ASSERT_EQUAL(XEDDSA_SIGNATURE_SIZE, direct.decoded.xeddsa_signature.size); + TEST_ASSERT_TRUE(direct.xeddsa_signed); +} + +void test_B9_licensed_unicast_never_uses_pki_encryption(void) +{ + uint8_t localPub[32], localPriv[32], remotePub[32], remotePriv[32]; + crypto->generateKeyPair(localPub, localPriv); + memcpy(config.security.private_key.bytes, localPriv, sizeof(localPriv)); + config.security.private_key.size = sizeof(localPriv); + mockNodeDB->addNode(LOCAL_NODE); + mockNodeDB->setPublicKey(LOCAL_NODE, localPub); + mockNodeDB->addNode(REMOTE_NODE); + crypto->generateKeyPair(remotePub, remotePriv); + mockNodeDB->setPublicKey(REMOTE_NODE, remotePub); + crypto->setDHPrivateKey(localPriv); + + owner.is_licensed = true; + channels.ensureLicensedOperation(); + meshtastic_MeshPacket p = makeDecoded(LOCAL_NODE, REMOTE_NODE, meshtastic_PortNum_TEXT_MESSAGE_APP, SMALL_PAYLOAD); + + TEST_ASSERT_EQUAL(meshtastic_Routing_Error_NONE, perhapsEncode(&p)); + TEST_ASSERT_FALSE(p.pki_encrypted); + meshtastic_Data plaintext = meshtastic_Data_init_zero; + TEST_ASSERT_TRUE(pb_decode_from_bytes(p.encrypted.bytes, p.encrypted.size, &meshtastic_Data_msg, &plaintext)); + TEST_ASSERT_EQUAL(XEDDSA_SIGNATURE_SIZE, plaintext.xeddsa_signature.size); +} + +void test_B10_licensed_oversized_unicast_remains_unsigned(void) +{ + owner.is_licensed = true; + channels.ensureLicensedOperation(); + meshtastic_MeshPacket p = makeDecoded(LOCAL_NODE, REMOTE_NODE, meshtastic_PortNum_TEXT_MESSAGE_APP, OVERSIZED_PAYLOAD); + + TEST_ASSERT_EQUAL(DECODE_SUCCESS, roundTrip(&p)); + TEST_ASSERT_EQUAL(0, p.decoded.xeddsa_signature.size); +} + +void test_B11_normal_unicast_still_uses_pki(void) +{ + uint8_t localPub[32], localPriv[32], remotePub[32], remotePriv[32]; + crypto->generateKeyPair(localPub, localPriv); + crypto->generateKeyPair(remotePub, remotePriv); + mockNodeDB->addNode(LOCAL_NODE); + mockNodeDB->setPublicKey(LOCAL_NODE, localPub); + mockNodeDB->addNode(REMOTE_NODE); + mockNodeDB->setPublicKey(REMOTE_NODE, remotePub); + memcpy(config.security.private_key.bytes, localPriv, sizeof(localPriv)); + config.security.private_key.size = sizeof(localPriv); + crypto->setDHPrivateKey(localPriv); + + meshtastic_MeshPacket p = makeDecoded(LOCAL_NODE, REMOTE_NODE, meshtastic_PortNum_TEXT_MESSAGE_APP, SMALL_PAYLOAD); + TEST_ASSERT_EQUAL(meshtastic_Routing_Error_NONE, perhapsEncode(&p)); + TEST_ASSERT_TRUE(p.pki_encrypted); + + myNodeInfo.my_node_num = REMOTE_NODE; + crypto->setDHPrivateKey(remotePriv); + TEST_ASSERT_EQUAL(DECODE_SUCCESS, perhapsDecode(&p)); + TEST_ASSERT_TRUE(p.pki_encrypted); + TEST_ASSERT_EQUAL(0, p.decoded.xeddsa_signature.size); +} + +void test_B12_licensed_receiver_does_not_decrypt_pki(void) +{ + uint8_t localPub[32], localPriv[32], remotePub[32], remotePriv[32]; + crypto->generateKeyPair(localPub, localPriv); + crypto->generateKeyPair(remotePub, remotePriv); + mockNodeDB->addNode(LOCAL_NODE); + mockNodeDB->setPublicKey(LOCAL_NODE, localPub); + mockNodeDB->addNode(REMOTE_NODE); + mockNodeDB->setPublicKey(REMOTE_NODE, remotePub); + memcpy(config.security.private_key.bytes, localPriv, sizeof(localPriv)); + config.security.private_key.size = sizeof(localPriv); + crypto->setDHPrivateKey(localPriv); + + meshtastic_MeshPacket p = makeDecoded(LOCAL_NODE, REMOTE_NODE, meshtastic_PortNum_TEXT_MESSAGE_APP, SMALL_PAYLOAD); + TEST_ASSERT_EQUAL(meshtastic_Routing_Error_NONE, perhapsEncode(&p)); + TEST_ASSERT_TRUE(p.pki_encrypted); + + owner.is_licensed = true; + channels.ensureLicensedOperation(); + myNodeInfo.my_node_num = REMOTE_NODE; + crypto->setDHPrivateKey(remotePriv); + TEST_ASSERT_EQUAL(DECODE_FAILURE, perhapsDecode(&p)); +} + +void test_B13_licensed_port_and_destination_signing_matrix(void) +{ + uint8_t pub[32], priv[32]; + crypto->generateKeyPair(pub, priv); + mockNodeDB->addNode(LOCAL_NODE); + mockNodeDB->setPublicKey(LOCAL_NODE, pub); + owner.is_licensed = true; + channels.ensureLicensedOperation(); + + const meshtastic_PortNum ports[] = { + meshtastic_PortNum_TEXT_MESSAGE_APP, meshtastic_PortNum_POSITION_APP, meshtastic_PortNum_TELEMETRY_APP, + meshtastic_PortNum_ROUTING_APP, meshtastic_PortNum_NODEINFO_APP, + }; + const NodeNum destinations[] = {NODENUM_BROADCAST, REMOTE_NODE}; + for (const auto port : ports) { + for (const auto destination : destinations) { + meshtastic_MeshPacket packet = makeDecoded(LOCAL_NODE, destination, port, SMALL_PAYLOAD); + TEST_ASSERT_EQUAL(DECODE_SUCCESS, roundTrip(&packet)); + TEST_ASSERT_EQUAL(XEDDSA_SIGNATURE_SIZE, packet.decoded.xeddsa_signature.size); + TEST_ASSERT_TRUE(packet.xeddsa_signed); + TEST_ASSERT_FALSE(packet.pki_encrypted); + } + } +} + // =========================================================================== -// Group C - NodeInfoModule downgrade drop (broadcast-only backstop for ingress paths that skip -// Router's check; unicast NodeInfo is never signed by senders, so it is exempt - see C4) +// Group C - routing pipeline and NodeInfo authentication ordering // =========================================================================== + class NodeInfoTestShim : public NodeInfoModule { public: - using NodeInfoModule::handleReceivedProtobuf; // protected virtual -> exposed for direct call + using NodeInfoModule::allocReply; + using NodeInfoModule::handleReceivedProtobuf; }; static meshtastic_MeshPacket makeNodeInfoPacket(bool signed_) { - // Broadcast so the module's phone-forward path (which needs `service`) is skipped. meshtastic_MeshPacket mp = makeDecoded(REMOTE_NODE, NODENUM_BROADCAST, meshtastic_PortNum_NODEINFO_APP, SMALL_PAYLOAD); mp.xeddsa_signed = signed_; return mp; } -// C1: unsigned NodeInfo from a node that previously signed -> dropped. -void test_C1_unsigned_nodeinfo_from_signer_dropped(void) +void test_N1_unsigned_nodeinfo_from_signer_dropped(void) { mockNodeDB->addNode(REMOTE_NODE); mockNodeDB->setSignerBit(REMOTE_NODE, true); NodeInfoTestShim shim; - meshtastic_MeshPacket mp = makeNodeInfoPacket(/*signed_=*/false); + meshtastic_MeshPacket mp = makeNodeInfoPacket(false); meshtastic_User user = meshtastic_User_init_zero; user.is_licensed = owner.is_licensed; TEST_ASSERT_TRUE_MESSAGE(shim.handleReceivedProtobuf(mp, &user), "unsigned NodeInfo from signer must be dropped"); } -// C2: signed NodeInfo from a known signer -> not dropped by this rule. -void test_C2_signed_nodeinfo_from_signer_not_dropped(void) +void test_N2_signed_nodeinfo_from_signer_not_dropped(void) { mockNodeDB->addNode(REMOTE_NODE); mockNodeDB->setSignerBit(REMOTE_NODE, true); NodeInfoTestShim shim; - meshtastic_MeshPacket mp = makeNodeInfoPacket(/*signed_=*/true); + meshtastic_MeshPacket mp = makeNodeInfoPacket(true); meshtastic_User user = meshtastic_User_init_zero; user.is_licensed = owner.is_licensed; TEST_ASSERT_FALSE(shim.handleReceivedProtobuf(mp, &user)); } -// C3: unsigned NodeInfo from a node we've never seen sign -> not dropped. -void test_C3_unsigned_nodeinfo_from_nonsigner_not_dropped(void) +void test_N3_unsigned_nodeinfo_from_nonsigner_not_dropped(void) { - mockNodeDB->addNode(REMOTE_NODE); // signer bit clear + mockNodeDB->addNode(REMOTE_NODE); NodeInfoTestShim shim; - meshtastic_MeshPacket mp = makeNodeInfoPacket(/*signed_=*/false); + meshtastic_MeshPacket mp = makeNodeInfoPacket(false); meshtastic_User user = meshtastic_User_init_zero; user.is_licensed = owner.is_licensed; TEST_ASSERT_FALSE(shim.handleReceivedProtobuf(mp, &user)); } -// C4: F1 regression - unsigned UNICAST NodeInfo from a known signer -> NOT dropped. Unicast -// NodeInfo (want_response replies, phone-initiated exchanges) is never signed by the sender, -// so treating it as a downgrade broke NodeInfo exchange with signer nodes. -void test_C4_unsigned_unicast_nodeinfo_from_signer_accepted(void) +void test_N4_unsigned_unicast_nodeinfo_from_signer_accepted(void) { mockNodeDB->addNode(REMOTE_NODE); mockNodeDB->setSignerBit(REMOTE_NODE, true); @@ -615,7 +1127,471 @@ void test_C4_unsigned_unicast_nodeinfo_from_signer_accepted(void) user.is_licensed = owner.is_licensed; TEST_ASSERT_FALSE_MESSAGE(shim.handleReceivedProtobuf(mp, &user), - "unsigned unicast NodeInfo from a signer must not be dropped"); + "unsigned unicast NodeInfo from signer must not be dropped"); +} + +static void preparePipelineSigner(NodeNum sender) +{ + uint8_t pub[32], priv[32]; + crypto->generateKeyPair(pub, priv); + mockNodeDB->addNode(sender); + mockNodeDB->setPublicKey(sender, pub); +} + +static void runPipelineIngress(const meshtastic_MeshPacket &p) +{ + meshtastic_MeshPacket *copy = packetPool.allocCopy(p); + TEST_ASSERT_NOT_NULL(copy); + pipelineRouter->enqueueReceivedMessage(copy); + pipelineRouter->runOnce(); +} + +static void assertNoRejectedPipelineEffects(NodeNum sender, uint32_t lastHeardBefore) +{ + TEST_ASSERT_EQUAL(0, pipelineRadio->sendCalls); + TEST_ASSERT_EQUAL(0, pipelineRadio->cancelCalls); + TEST_ASSERT_EQUAL(0, pipelineRadio->findCalls); + TEST_ASSERT_EQUAL(0, pipelineRadio->removeCalls); + TEST_ASSERT_EQUAL(0, pipelineRouting->ackCalls); + TEST_ASSERT_EQUAL(0, pipelineRouter->rxDupe); + TEST_ASSERT_EQUAL(0, pipelineRouter->txRelayCanceled); + TEST_ASSERT_EQUAL(0, pipelineModule->calls); + TEST_ASSERT_EQUAL(0, pipelineMqtt->queueSize()); + TEST_ASSERT_NULL(pipelineService->getForPhone()); + const meshtastic_NodeInfoLite *node = mockNodeDB->getMeshNode(sender); + TEST_ASSERT_NOT_NULL(node); + TEST_ASSERT_EQUAL_UINT32(lastHeardBefore, node->last_heard); +} + +void test_C1_invalid_first_copy_does_not_poison_valid_same_id(void) +{ + setPolicy(meshtastic_Config_SecurityConfig_PacketSignaturePolicy_PACKET_SIGNATURE_POLICY_STRICT); + preparePipelineSigner(REMOTE_NODE); + const PacketId id = 0xC1000001; + const uint32_t lastHeard = mockNodeDB->getMeshNode(REMOTE_NODE)->last_heard; + + meshtastic_MeshPacket invalid = makeSignedWirePacket(REMOTE_NODE, NODENUM_BROADCAST, id, 1, 2, 0, 0x31, false); + moduleConfig.mqtt.enabled = true; + runPipelineIngress(invalid); + assertNoRejectedPipelineEffects(REMOTE_NODE, lastHeard); + TEST_ASSERT_FALSE(pipelineRouter->historyContains(&invalid)); + + meshtastic_MeshPacket valid = makeSignedWirePacket(REMOTE_NODE, NODENUM_BROADCAST, id); + TEST_ASSERT_EQUAL(static_cast(RoutingAuthVerdict::ACCEPT), static_cast(passesRoutingAuthGate(&valid))); + TEST_ASSERT_EQUAL_MESSAGE(meshtastic_MeshPacket_encrypted_tag, valid.which_payload_variant, + "routing auth gate must preserve encrypted relay/MQTT bytes"); + TEST_ASSERT_FALSE_MESSAGE(pipelineRouter->filter(&valid), "valid same-ID packet was poisoned by rejected first copy"); + TEST_ASSERT_TRUE(pipelineRouter->historyContains(&valid)); +} + +void test_C2_invalid_ordinary_duplicate_has_no_cancel_or_delivery_effects(void) +{ + setPolicy(meshtastic_Config_SecurityConfig_PacketSignaturePolicy_PACKET_SIGNATURE_POLICY_STRICT); + preparePipelineSigner(REMOTE_NODE); + const PacketId id = 0xC2000002; + meshtastic_MeshPacket prior = makeDecoded(REMOTE_NODE, NODENUM_BROADCAST, meshtastic_PortNum_POSITION_APP, SMALL_PAYLOAD); + prior.id = id; + prior.hop_limit = 1; + prior.hop_start = 2; + prior.transport_mechanism = meshtastic_MeshPacket_TransportMechanism_TRANSPORT_LORA; + pipelineRouter->remember(&prior); + const uint32_t lastHeard = mockNodeDB->getMeshNode(REMOTE_NODE)->last_heard; + + meshtastic_MeshPacket invalid = makeSignedWirePacket(REMOTE_NODE, NODENUM_BROADCAST, id, 1, 2, 0, 0x32, false); + runPipelineIngress(invalid); + assertNoRejectedPipelineEffects(REMOTE_NODE, lastHeard); + TEST_ASSERT_TRUE(pipelineRouter->historyContains(&prior)); +} + +void test_C3_invalid_repeated_packet_cannot_ack_or_change_retry_state(void) +{ + setPolicy(meshtastic_Config_SecurityConfig_PacketSignaturePolicy_PACKET_SIGNATURE_POLICY_STRICT); + preparePipelineSigner(LOCAL_NODE); + const PacketId id = 0xC3000003; + meshtastic_MeshPacket prior = makeDecoded(LOCAL_NODE, NODENUM_BROADCAST, meshtastic_PortNum_POSITION_APP, SMALL_PAYLOAD); + prior.id = id; + prior.hop_limit = 2; + prior.hop_start = 2; + prior.transport_mechanism = meshtastic_MeshPacket_TransportMechanism_TRANSPORT_LORA; + pipelineRouter->remember(&prior); + pipelineRouter->addPending(prior, UINT32_MAX); + const uint32_t lastHeard = mockNodeDB->getMeshNode(LOCAL_NODE)->last_heard; + + meshtastic_MeshPacket invalid = makeSignedWirePacket(LOCAL_NODE, NODENUM_BROADCAST, id, 2, 2, 0, 0x34, false); + runPipelineIngress(invalid); + assertNoRejectedPipelineEffects(LOCAL_NODE, lastHeard); + TEST_ASSERT_EQUAL(1, pipelineRouter->pendingCount()); + TEST_ASSERT_EQUAL_UINT32(UINT32_MAX, pipelineRouter->pendingNextTx(LOCAL_NODE, id)); +} + +void test_C4_invalid_fallback_packet_cannot_relay(void) +{ + setPolicy(meshtastic_Config_SecurityConfig_PacketSignaturePolicy_PACKET_SIGNATURE_POLICY_STRICT); + preparePipelineSigner(REMOTE_NODE); + const PacketId id = 0xC4000004; + const uint8_t ourRelay = mockNodeDB->getLastByteOfNodeNum(LOCAL_NODE); + meshtastic_MeshPacket prior = makeDecoded(REMOTE_NODE, LOCAL_NODE, meshtastic_PortNum_POSITION_APP, SMALL_PAYLOAD); + prior.id = id; + prior.next_hop = 0x22; + prior.relay_node = ourRelay; + prior.hop_limit = 1; + prior.hop_start = 2; + pipelineRouter->remember(&prior); + meshtastic_MeshPacket relayed = prior; + relayed.relay_node = 0x35; + pipelineRouter->remember(&relayed); + pipelineRouter->forgetRelayer(ourRelay, id, REMOTE_NODE); + const uint32_t lastHeard = mockNodeDB->getMeshNode(REMOTE_NODE)->last_heard; + + meshtastic_MeshPacket invalid = makeSignedWirePacket(REMOTE_NODE, LOCAL_NODE, id, 1, 2, 0, 0x35, false); + runPipelineIngress(invalid); + assertNoRejectedPipelineEffects(REMOTE_NODE, lastHeard); +} + +void test_C5_invalid_upgrade_cannot_remove_pending_valid_send(void) +{ + setPolicy(meshtastic_Config_SecurityConfig_PacketSignaturePolicy_PACKET_SIGNATURE_POLICY_STRICT); + preparePipelineSigner(REMOTE_NODE); + const PacketId id = 0xC5000005; + meshtastic_MeshPacket prior = makeDecoded(REMOTE_NODE, NODENUM_BROADCAST, meshtastic_PortNum_POSITION_APP, SMALL_PAYLOAD); + prior.id = id; + prior.hop_limit = 1; + prior.hop_start = 2; + pipelineRouter->remember(&prior); + const uint32_t lastHeard = mockNodeDB->getMeshNode(REMOTE_NODE)->last_heard; + + meshtastic_MeshPacket directInvalid = makeSignedWirePacket(REMOTE_NODE, NODENUM_BROADCAST, id, 2, 2, 0, 0x36, false); + TEST_ASSERT_TRUE(pipelineRouter->handleUpgrade(&directInvalid)); + TEST_ASSERT_EQUAL(0, pipelineRadio->removeCalls); + + meshtastic_MeshPacket invalid = makeSignedWirePacket(REMOTE_NODE, NODENUM_BROADCAST, id, 2, 2, 0, 0x36, false); + runPipelineIngress(invalid); + assertNoRejectedPipelineEffects(REMOTE_NODE, lastHeard); + + // The rejected upgrade did not raise the history watermark or remove the queued valid copy; + // a later authenticated replacement still performs the intended upgrade. + meshtastic_MeshPacket valid = makeSignedWirePacket(REMOTE_NODE, NODENUM_BROADCAST, id, 2, 2, 0, 0x36, true); + TEST_ASSERT_EQUAL(static_cast(RoutingAuthVerdict::ACCEPT), static_cast(passesRoutingAuthGate(&valid))); + TEST_ASSERT_TRUE(pipelineRouter->filter(&valid)); + TEST_ASSERT_EQUAL(1, pipelineRadio->removeCalls); +} + +void test_C6_opaque_unknown_channel_is_relay_only(void) +{ + setPolicy(meshtastic_Config_SecurityConfig_PacketSignaturePolicy_PACKET_SIGNATURE_POLICY_STRICT); + meshtastic_MeshPacket opaque = meshtastic_MeshPacket_init_zero; + opaque.from = REMOTE_NODE; + opaque.to = NODENUM_BROADCAST; + opaque.id = 0xC6000006; + opaque.channel = 0xFE; + opaque.hop_limit = 1; + opaque.hop_start = 2; + opaque.which_payload_variant = meshtastic_MeshPacket_encrypted_tag; + opaque.encrypted.size = 16; + memset(opaque.encrypted.bytes, 0xA5, opaque.encrypted.size); + + TEST_ASSERT_EQUAL(static_cast(RoutingAuthVerdict::OPAQUE_RELAY_ONLY), static_cast(passesRoutingAuthGate(&opaque))); + moduleConfig.mqtt.enabled = true; + runPipelineIngress(opaque); + TEST_ASSERT_EQUAL_MESSAGE(1, pipelineRadio->sendCalls, "opaque broadcast should take only the safety-controlled relay path"); + TEST_ASSERT_EQUAL(0, pipelineRouting->ackCalls); + TEST_ASSERT_EQUAL(0, pipelineModule->calls); + TEST_ASSERT_EQUAL(0, pipelineMqtt->queueSize()); + TEST_ASSERT_NULL(pipelineService->getForPhone()); + TEST_ASSERT_FALSE(pipelineRouter->historyContains(&opaque)); + TEST_ASSERT_NULL(mockNodeDB->getMeshNode(REMOTE_NODE)); + + pipelineRadio->reset(); + meshtastic_MeshPacket addressed = opaque; + addressed.to = LOCAL_NODE; + addressed.id++; + runPipelineIngress(addressed); + TEST_ASSERT_EQUAL_MESSAGE(0, pipelineRadio->sendCalls, "opaque packet addressed to us must not be relayed"); + TEST_ASSERT_EQUAL(0, pipelineRouting->ackCalls); + TEST_ASSERT_EQUAL(0, pipelineModule->calls); + TEST_ASSERT_EQUAL(0, pipelineMqtt->queueSize()); + TEST_ASSERT_NULL(pipelineService->getForPhone()); + TEST_ASSERT_FALSE(pipelineRouter->historyContains(&addressed)); + + const meshtastic_Config_DeviceConfig_RebroadcastMode blockedModes[] = { + meshtastic_Config_DeviceConfig_RebroadcastMode_LOCAL_ONLY, + meshtastic_Config_DeviceConfig_RebroadcastMode_CORE_PORTNUMS_ONLY, + meshtastic_Config_DeviceConfig_RebroadcastMode_NONE, + }; + for (const auto mode : blockedModes) { + pipelineRadio->reset(); + config.device.rebroadcast_mode = mode; + meshtastic_MeshPacket blocked = opaque; + blocked.id++; + blocked.id += static_cast(mode); + blocked.transport_mechanism = meshtastic_MeshPacket_TransportMechanism_TRANSPORT_MULTICAST_UDP; + runPipelineIngress(blocked); + TEST_ASSERT_EQUAL_MESSAGE(0, pipelineRadio->sendCalls, "restricted rebroadcast mode must suppress opaque relay"); + TEST_ASSERT_EQUAL(0, pipelineRouting->ackCalls); + TEST_ASSERT_EQUAL(0, pipelineModule->calls); + TEST_ASSERT_EQUAL(0, pipelineMqtt->queueSize()); + TEST_ASSERT_NULL(pipelineService->getForPhone()); + TEST_ASSERT_FALSE(pipelineRouter->historyContains(&blocked)); + } +} + +void test_C7_strict_rejects_unsigned_decoded_simradio_ingress(void) +{ + setPolicy(meshtastic_Config_SecurityConfig_PacketSignaturePolicy_PACKET_SIGNATURE_POLICY_STRICT); + mockNodeDB->addNode(REMOTE_NODE); + const uint32_t lastHeard = mockNodeDB->getMeshNode(REMOTE_NODE)->last_heard; + meshtastic_MeshPacket injected = makeDecoded(REMOTE_NODE, NODENUM_BROADCAST, meshtastic_PortNum_POSITION_APP, SMALL_PAYLOAD); + injected.transport_mechanism = meshtastic_MeshPacket_TransportMechanism_TRANSPORT_LORA; + runPipelineIngress(injected); + assertNoRejectedPipelineEffects(REMOTE_NODE, lastHeard); + TEST_ASSERT_FALSE(pipelineRouter->historyContains(&injected)); +} + +void test_C8_trusted_local_decoded_delivery_is_not_filtered(void) +{ + setPolicy(meshtastic_Config_SecurityConfig_PacketSignaturePolicy_PACKET_SIGNATURE_POLICY_STRICT); + meshtastic_MeshPacket *local = + packetPool.allocCopy(makeDecoded(0, LOCAL_NODE, meshtastic_PortNum_POSITION_APP, SMALL_PAYLOAD)); + TEST_ASSERT_NOT_NULL(local); + TEST_ASSERT_EQUAL(ERRNO_SHOULD_RELEASE, pipelineRouter->sendLocal(local, RX_SRC_USER)); + TEST_ASSERT_EQUAL_MESSAGE(1, pipelineModule->calls, "trusted phone-origin packet must reach local modules"); + packetPool.release(local); +} + +void test_C9_known_channel_malformed_plaintext_is_not_relayed_as_opaque(void) +{ + meshtastic_MeshPacket malformed = meshtastic_MeshPacket_init_zero; + malformed.from = REMOTE_NODE; + malformed.to = NODENUM_BROADCAST; + malformed.id = 0xC9000009; + malformed.which_payload_variant = meshtastic_MeshPacket_encrypted_tag; + malformed.encrypted.size = 3; + malformed.encrypted.bytes[0] = 0xFF; + malformed.encrypted.bytes[1] = 0xFF; + malformed.encrypted.bytes[2] = 0xFF; + malformed.channel = channels.setActiveByIndex(0); + crypto->encryptPacket(malformed.from, malformed.id, malformed.encrypted.size, malformed.encrypted.bytes); + mockNodeDB->addNode(REMOTE_NODE); + const uint32_t lastHeard = mockNodeDB->getMeshNode(REMOTE_NODE)->last_heard; + runPipelineIngress(malformed); + assertNoRejectedPipelineEffects(REMOTE_NODE, lastHeard); + TEST_ASSERT_FALSE(pipelineRouter->historyContains(&malformed)); +} + +void test_C10_legacy_channel_dm_failure_has_no_pipeline_effects(void) +{ + meshtastic_MeshPacket legacyDm = makeDecoded(REMOTE_NODE, LOCAL_NODE, meshtastic_PortNum_TEXT_MESSAGE_APP, SMALL_PAYLOAD); + legacyDm = channelEncode(legacyDm); + mockNodeDB->addNode(REMOTE_NODE); + const uint32_t lastHeard = mockNodeDB->getMeshNode(REMOTE_NODE)->last_heard; + moduleConfig.mqtt.enabled = true; + runPipelineIngress(legacyDm); + assertNoRejectedPipelineEffects(REMOTE_NODE, lastHeard); + TEST_ASSERT_FALSE(pipelineRouter->historyContains(&legacyDm)); +} + +void test_C11_malformed_pki_plaintext_has_no_pipeline_effects(void) +{ + uint8_t localPub[32], localPriv[32], remotePub[32], remotePriv[32]; + crypto->generateKeyPair(localPub, localPriv); + crypto->generateKeyPair(remotePub, remotePriv); + mockNodeDB->addNode(LOCAL_NODE); + mockNodeDB->setPublicKey(LOCAL_NODE, localPub); + mockNodeDB->addNode(REMOTE_NODE); + mockNodeDB->setPublicKey(REMOTE_NODE, remotePub); + + const uint8_t malformedPlaintext[] = {0xFF, 0xFF, 0xFF}; + meshtastic_NodeInfoLite_public_key_t localKey = {32, {0}}; + memcpy(localKey.bytes, localPub, sizeof(localPub)); + meshtastic_MeshPacket malformed = meshtastic_MeshPacket_init_zero; + malformed.from = REMOTE_NODE; + malformed.to = LOCAL_NODE; + malformed.id = 0xCB00000B; + malformed.channel = 0; + malformed.which_payload_variant = meshtastic_MeshPacket_encrypted_tag; + crypto->setDHPrivateKey(remotePriv); + TEST_ASSERT_TRUE(crypto->encryptCurve25519(malformed.to, malformed.from, localKey, malformed.id, sizeof(malformedPlaintext), + malformedPlaintext, malformed.encrypted.bytes)); + malformed.encrypted.size = sizeof(malformedPlaintext) + MESHTASTIC_PKC_OVERHEAD; + crypto->setDHPrivateKey(localPriv); + + const uint32_t lastHeard = mockNodeDB->getMeshNode(REMOTE_NODE)->last_heard; + moduleConfig.mqtt.enabled = true; + runPipelineIngress(malformed); + assertNoRejectedPipelineEffects(REMOTE_NODE, lastHeard); + TEST_ASSERT_FALSE(pipelineRouter->historyContains(&malformed)); +} + +void test_C12_exact_authenticated_replay_reuses_verdict_without_collision_bypass(void) +{ + setPolicy(meshtastic_Config_SecurityConfig_PacketSignaturePolicy_PACKET_SIGNATURE_POLICY_STRICT); + preparePipelineSigner(REMOTE_NODE); + meshtastic_MeshPacket valid = makeSignedWirePacket(REMOTE_NODE, NODENUM_BROADCAST, 0xCC00000C); + // Full ingress replaces this nonzero wire timestamp with the local arrival time. The exact + // authentication handoff must be consumed before that mutation, avoiding a second evaluation. + valid.rx_time = 0x12345678; + runPipelineIngress(valid); + TEST_ASSERT_EQUAL_MESSAGE(1, routingAuthEvaluationCount(), "full ingress must consume the primed verdict exactly once"); + runPipelineIngress(valid); + TEST_ASSERT_EQUAL_MESSAGE(2, routingAuthEvaluationCount(), "consumed verdict must not authenticate a later replay"); + + meshtastic_MeshPacket collision = valid; + collision.encrypted.bytes[0] ^= 0x80; + TEST_ASSERT_EQUAL(static_cast(RoutingAuthVerdict::REJECT), static_cast(passesRoutingAuthGate(&collision))); + TEST_ASSERT_EQUAL_MESSAGE(3, routingAuthEvaluationCount(), "same packet ID with different bytes must be reevaluated"); +} + +// C5: the packet survives (C4) but the identity claim inside it must not land - the pubkey guard +// can't tell a signer from an impersonator replaying its (public) key. Only the write is refused. +void test_N5_unsigned_unicast_nodeinfo_from_signer_does_not_change_name(void) +{ + mockNodeDB->addNode(REMOTE_NODE); + mockNodeDB->setSignerBit(REMOTE_NODE, true); + mockNodeDB->setLongName(REMOTE_NODE, "Genuine"); + + NodeInfoTestShim shim; + meshtastic_MeshPacket mp = makeDecoded(REMOTE_NODE, LOCAL_NODE, meshtastic_PortNum_NODEINFO_APP, SMALL_PAYLOAD); + mp.xeddsa_signed = false; + meshtastic_User user = meshtastic_User_init_zero; + user.is_licensed = owner.is_licensed; + strcpy(user.long_name, "Spoofed"); + strcpy(user.short_name, "SPF"); + + TEST_ASSERT_FALSE_MESSAGE(shim.handleReceivedProtobuf(mp, &user), "the packet itself must still be accepted"); + TEST_ASSERT_EQUAL_STRING_MESSAGE("Genuine", mockNodeDB->longName(REMOTE_NODE), + "unsigned unicast NodeInfo from a signer must not rewrite its stored name"); +} + +// C6: the same exchange signed - the update is authenticated and must land, pinning C5 as a +// targeted refusal rather than a blanket block on unicast NodeInfo from signers. +void test_N6_signed_unicast_nodeinfo_from_signer_changes_name(void) +{ + mockNodeDB->addNode(REMOTE_NODE); + mockNodeDB->setSignerBit(REMOTE_NODE, true); + mockNodeDB->setLongName(REMOTE_NODE, "Genuine"); + + NodeInfoTestShim shim; + meshtastic_MeshPacket mp = makeDecoded(REMOTE_NODE, LOCAL_NODE, meshtastic_PortNum_NODEINFO_APP, SMALL_PAYLOAD); + mp.xeddsa_signed = true; + meshtastic_User user = meshtastic_User_init_zero; + user.is_licensed = owner.is_licensed; + strcpy(user.long_name, "Renamed"); + strcpy(user.short_name, "RNM"); + + TEST_ASSERT_FALSE(shim.handleReceivedProtobuf(mp, &user)); + TEST_ASSERT_EQUAL_STRING_MESSAGE("Renamed", mockNodeDB->longName(REMOTE_NODE), + "a signed update from a signer must still be learned"); +} + +// C7: a node that has never signed is unaffected - the ordinary case for most of the mesh. +void test_N7_unsigned_unicast_nodeinfo_from_nonsigner_changes_name(void) +{ + mockNodeDB->addNode(REMOTE_NODE); // signer bit clear + mockNodeDB->setLongName(REMOTE_NODE, "Genuine"); + + NodeInfoTestShim shim; + meshtastic_MeshPacket mp = makeDecoded(REMOTE_NODE, LOCAL_NODE, meshtastic_PortNum_NODEINFO_APP, SMALL_PAYLOAD); + mp.xeddsa_signed = false; + meshtastic_User user = meshtastic_User_init_zero; + user.is_licensed = owner.is_licensed; + strcpy(user.long_name, "Renamed"); + strcpy(user.short_name, "RNM"); + + TEST_ASSERT_FALSE(shim.handleReceivedProtobuf(mp, &user)); + TEST_ASSERT_EQUAL_STRING_MESSAGE("Renamed", mockNodeDB->longName(REMOTE_NODE), + "non-signer identity learning must be unaffected"); +} + +void test_L1_licensed_nodeinfo_publishes_public_key(void) +{ + owner.is_licensed = true; + owner.public_key.size = 32; + memset(owner.public_key.bytes, 0x5A, owner.public_key.size); + + NodeInfoTestShim shim; + meshtastic_MeshPacket *reply = shim.allocReply(); + TEST_ASSERT_NOT_NULL(reply); + meshtastic_User published = meshtastic_User_init_zero; + TEST_ASSERT_TRUE( + pb_decode_from_bytes(reply->decoded.payload.bytes, reply->decoded.payload.size, &meshtastic_User_msg, &published)); + TEST_ASSERT_TRUE(published.is_licensed); + TEST_ASSERT_EQUAL(32, published.public_key.size); + TEST_ASSERT_EQUAL_UINT8_ARRAY(owner.public_key.bytes, published.public_key.bytes, 32); + packetPool.release(reply); +} + +void test_L2_licensed_identity_key_is_generated_and_preserved(void) +{ + owner.is_licensed = true; + config.lora.region = meshtastic_Config_LoRaConfig_RegionCode_US; + config.security = meshtastic_Config_SecurityConfig_init_zero; + + TEST_ASSERT_TRUE(mockNodeDB->generateCryptoKeyPair()); + uint8_t privateKey[32], publicKey[32]; + memcpy(privateKey, config.security.private_key.bytes, sizeof(privateKey)); + memcpy(publicKey, config.security.public_key.bytes, sizeof(publicKey)); + const NodeNum migratedNodeNum = myNodeInfo.my_node_num; + TEST_ASSERT_NOT_EQUAL(LOCAL_NODE, migratedNodeNum); + TEST_ASSERT_TRUE(mockNodeDB->licensedIdentityMigrationPending); + + MockMeshService mockService; + service = &mockService; + TEST_ASSERT_TRUE(mockNodeDB->notifyPendingLicensedIdentityMigration()); + TEST_ASSERT_EQUAL(1, mockService.notificationCount); + TEST_ASSERT_FALSE(mockNodeDB->licensedIdentityMigrationPending); + TEST_ASSERT_FALSE(mockNodeDB->notifyPendingLicensedIdentityMigration()); + TEST_ASSERT_EQUAL(1, mockService.notificationCount); + service = pipelineService; + + config.security.public_key.size = 0; + owner.public_key.size = 0; + TEST_ASSERT_TRUE(mockNodeDB->generateCryptoKeyPair()); + TEST_ASSERT_EQUAL(migratedNodeNum, myNodeInfo.my_node_num); + TEST_ASSERT_EQUAL_UINT8_ARRAY(privateKey, config.security.private_key.bytes, sizeof(privateKey)); + TEST_ASSERT_EQUAL_UINT8_ARRAY(publicKey, config.security.public_key.bytes, sizeof(publicKey)); + TEST_ASSERT_EQUAL_UINT8_ARRAY(publicKey, owner.public_key.bytes, sizeof(publicKey)); +} + +void test_L3_factory_config_reset_preserves_valid_identity_private_key(void) +{ + uint8_t publicKey[32], privateKey[32]; + crypto->generateKeyPair(publicKey, privateKey); + config.has_security = true; + config.security.private_key.size = 32; + memcpy(config.security.private_key.bytes, privateKey, sizeof(privateKey)); + + mockNodeDB->installDefaultsPreservingIdentity(); + TEST_ASSERT_EQUAL(32, config.security.private_key.size); + TEST_ASSERT_EQUAL_UINT8_ARRAY(privateKey, config.security.private_key.bytes, sizeof(privateKey)); + TEST_ASSERT_EQUAL(0, config.security.public_key.size); + + owner.is_licensed = true; + config.lora.region = meshtastic_Config_LoRaConfig_RegionCode_US; + TEST_ASSERT_TRUE(mockNodeDB->generateCryptoKeyPair()); + TEST_ASSERT_EQUAL_UINT8_ARRAY(privateKey, config.security.private_key.bytes, sizeof(privateKey)); + TEST_ASSERT_EQUAL_UINT8_ARRAY(publicKey, config.security.public_key.bytes, sizeof(publicKey)); +} + +void test_L4_licensed_low_entropy_identity_is_regenerated(void) +{ + static const uint8_t compromisedPublicKey[32] = { + 0xac, 0xaf, 0x8c, 0x1c, 0x3c, 0x1c, 0x37, 0xac, 0x4f, 0x03, 0xa1, 0xe9, 0xfc, 0x37, 0x23, 0x29, + 0xc8, 0xa3, 0x5d, 0x7f, 0x05, 0x26, 0xeb, 0x00, 0xbd, 0x26, 0xb8, 0x2e, 0xb1, 0x94, 0x7d, 0x24, + }; + owner.is_licensed = true; + config.lora.region = meshtastic_Config_LoRaConfig_RegionCode_US; + config.security.private_key.size = 32; + memset(config.security.private_key.bytes, 0xA5, 32); + config.security.public_key.size = 32; + memcpy(config.security.public_key.bytes, compromisedPublicKey, sizeof(compromisedPublicKey)); + TEST_ASSERT_TRUE(mockNodeDB->checkLowEntropyPublicKey(config.security.public_key)); + + uint8_t oldPrivateKey[32]; + memcpy(oldPrivateKey, config.security.private_key.bytes, sizeof(oldPrivateKey)); + TEST_ASSERT_TRUE(mockNodeDB->generateCryptoKeyPair()); + TEST_ASSERT_TRUE(mockNodeDB->keyIsLowEntropy); + TEST_ASSERT_FALSE(mockNodeDB->checkLowEntropyPublicKey(config.security.public_key)); + TEST_ASSERT_FALSE(memcmp(oldPrivateKey, config.security.private_key.bytes, sizeof(oldPrivateKey)) == 0); } // =========================================================================== @@ -715,7 +1691,7 @@ void test_E5_decoded_unsigned_oversized_broadcast_from_signer_accepted(void) TEST_ASSERT_TRUE(checkXeddsaReceivePolicy(&p)); } -// E6: unsigned unicast from a signer -> accepted (unicast is never signed). +// E6: Balanced accepts unsigned unicast from a signer for legacy compatibility. void test_E6_decoded_unsigned_unicast_from_signer_accepted(void) { mockNodeDB->addNode(REMOTE_NODE); @@ -726,20 +1702,6 @@ void test_E6_decoded_unsigned_unicast_from_signer_accepted(void) TEST_ASSERT_TRUE(checkXeddsaReceivePolicy(&p)); } -// E7: unsigned PKI-flagged packet from a signer -> accepted. Senders never sign PKI traffic, -// so the predicate's !pki_encrypted guard must exempt it (pins the assumption that the -// downgrade drop can never fire on PKI packets, whatever their addressing). -void test_E7_decoded_unsigned_pki_from_signer_accepted(void) -{ - mockNodeDB->addNode(REMOTE_NODE); - mockNodeDB->setSignerBit(REMOTE_NODE, true); - - meshtastic_MeshPacket p = makeDecoded(REMOTE_NODE, NODENUM_BROADCAST, meshtastic_PortNum_TEXT_MESSAGE_APP, SMALL_PAYLOAD); - p.pki_encrypted = true; - - TEST_ASSERT_TRUE(checkXeddsaReceivePolicy(&p)); -} - // E8: a crafted partial (non-0, non-64) signature must not let a forged broadcast dodge the // downgrade drop. A 63-byte junk signature inflates the encoded size past the fit threshold, so // a size-only predicate would treat the packet as "too big to sign" and accept it as an @@ -772,9 +1734,125 @@ void test_E9_decoded_partial_signature_from_nonsigner_dropped(void) TEST_ASSERT_FALSE_MESSAGE(checkXeddsaReceivePolicy(&p), "partial signature must be dropped as malformed"); } +// Build an unsigned broadcast whose inner message is padded with an unknown field, and pin that the +// padding pushes the RAW size past the fit threshold - the exemption the attacker is buying - while +// the frame stays sendable. Without canonical inner sizing these packets are wrongly accepted. +static meshtastic_MeshPacket makePayloadPaddedBroadcast(meshtastic_PortNum port, const pb_msgdesc_t *fields, const void *inner, + size_t padLen) +{ + meshtastic_MeshPacket p = makeDecoded(REMOTE_NODE, NODENUM_BROADCAST, port, 0); + const size_t innerLen = pb_encode_to_bytes(p.decoded.payload.bytes, sizeof(p.decoded.payload.bytes), fields, inner); + TEST_ASSERT_GREATER_THAN_MESSAGE(0, innerLen, "failed to encode the spoofed inner message"); + p.decoded.payload.size = + innerLen + appendUnknownField(p.decoded.payload.bytes + innerLen, sizeof(p.decoded.payload.bytes) - innerLen, padLen); + + TEST_ASSERT_FALSE_MESSAGE(signedEncodingFits(&p.decoded), "padding must push the raw size past the fit threshold"); + TEST_ASSERT_LESS_OR_EQUAL_MESSAGE(MAX_LORA_PAYLOAD_LEN, encodedDataSize(&p.decoded) + MESHTASTIC_HEADER_LENGTH, + "padded frame must still be one a radio could send"); + return p; +} + +// E10: unknown fields buried inside Data.payload are discarded by the module's own pb_decode, so +// they must not sway the downgrade decision the way A10 already pins for Data-level unknown fields. +void test_E10_decoded_unsigned_position_padded_inside_payload_dropped(void) +{ + mockNodeDB->addNode(REMOTE_NODE); + mockNodeDB->setSignerBit(REMOTE_NODE, true); + + meshtastic_Position pos = meshtastic_Position_init_zero; + pos.has_latitude_i = pos.has_longitude_i = true; + pos.latitude_i = 371234567; + pos.longitude_i = -1221234567; + + meshtastic_MeshPacket p = makePayloadPaddedBroadcast(meshtastic_PortNum_POSITION_APP, &meshtastic_Position_msg, &pos, 163); + + TEST_ASSERT_FALSE_MESSAGE(checkXeddsaReceivePolicy(&p), "payload-padded unsigned Position from a signer must be dropped"); + TEST_ASSERT_FALSE(p.xeddsa_signed); +} + +// E11: over-correction guard. Telemetry (272 bytes max) and Waypoint (199) can legitimately exceed +// the signable budget, so canonical sizing must not shrink an honest one into the drop range. +void test_E11_decoded_unsigned_oversized_telemetry_from_signer_accepted(void) +{ + mockNodeDB->addNode(REMOTE_NODE); + mockNodeDB->setSignerBit(REMOTE_NODE, true); + + meshtastic_Telemetry t = meshtastic_Telemetry_init_zero; + t.which_variant = meshtastic_Telemetry_host_metrics_tag; + t.variant.host_metrics.uptime_seconds = 123456; + t.variant.host_metrics.has_user_string = true; + memset(t.variant.host_metrics.user_string, 'x', sizeof(t.variant.host_metrics.user_string) - 1); + + meshtastic_MeshPacket p = makeDecoded(REMOTE_NODE, NODENUM_BROADCAST, meshtastic_PortNum_TELEMETRY_APP, 0); + p.decoded.payload.size = + pb_encode_to_bytes(p.decoded.payload.bytes, sizeof(p.decoded.payload.bytes), &meshtastic_Telemetry_msg, &t); + TEST_ASSERT_GREATER_THAN_MESSAGE(0, p.decoded.payload.size, "failed to encode the oversized Telemetry"); + + // Every byte here is a field this build understands, so canonical sizing must leave it alone. + TEST_ASSERT_FALSE_MESSAGE(signedEncodingFits(&p.decoded), "telemetry must be too big to sign, else the test is vacuous"); + + TEST_ASSERT_TRUE_MESSAGE(checkXeddsaReceivePolicy(&p), "honest oversized telemetry from a signer must not be dropped"); +} + +// E12: E10 for the Waypoint branch of the canonical-sizing switch. +void test_E12_decoded_unsigned_waypoint_padded_inside_payload_dropped(void) +{ + mockNodeDB->addNode(REMOTE_NODE); + mockNodeDB->setSignerBit(REMOTE_NODE, true); + + meshtastic_Waypoint w = meshtastic_Waypoint_init_zero; + w.id = 42; + w.has_latitude_i = w.has_longitude_i = true; + w.latitude_i = 371234567; + w.longitude_i = -1221234567; + strcpy(w.name, "spoofed"); + + meshtastic_MeshPacket p = makePayloadPaddedBroadcast(meshtastic_PortNum_WAYPOINT_APP, &meshtastic_Waypoint_msg, &w, 150); + + TEST_ASSERT_FALSE_MESSAGE(checkXeddsaReceivePolicy(&p), "payload-padded unsigned Waypoint from a signer must be dropped"); + TEST_ASSERT_FALSE(p.xeddsa_signed); +} + +// E13: E10 for the NodeInfo/User branch. Router drops it before rebroadcast; NodeInfoModule's own +// check (Group C) is receiver-local and would not stop the packet propagating. +void test_E13_decoded_unsigned_nodeinfo_padded_inside_payload_dropped(void) +{ + mockNodeDB->addNode(REMOTE_NODE); + mockNodeDB->setSignerBit(REMOTE_NODE, true); + + meshtastic_User u = meshtastic_User_init_zero; + strcpy(u.id, "!0b0b0b0b"); + strcpy(u.long_name, "spoofed node"); + strcpy(u.short_name, "SPF"); + + meshtastic_MeshPacket p = makePayloadPaddedBroadcast(meshtastic_PortNum_NODEINFO_APP, &meshtastic_User_msg, &u, 150); + + TEST_ASSERT_FALSE_MESSAGE(checkXeddsaReceivePolicy(&p), "payload-padded unsigned NodeInfo from a signer must be dropped"); + TEST_ASSERT_FALSE(p.xeddsa_signed); +} + void setup() { initializeTestEnvironment(); + AirTime *savedAirTime = airTime; + meshtastic::NodeStatus *savedNodeStatus = nodeStatus; + AirTime testAirTime; + meshtastic::NodeStatus testNodeStatus; + airTime = &testAirTime; + nodeStatus = &testNodeStatus; + + config.lora.region = meshtastic_Config_LoRaConfig_RegionCode_US; + initRegion(); + pipelineRouter = new AuthPipelineRouter(); + auto pipelineRadioOwner = std::make_unique(); + pipelineRadio = pipelineRadioOwner.get(); + pipelineRouter->addInterface(std::move(pipelineRadioOwner)); + router = pipelineRouter; + routingModule = pipelineRouting = new AuthPipelineRoutingModule(); + pipelineModule = new AuthPipelineModule(); + service = pipelineService = new MeshService(); + mqtt = pipelineMqtt = new AuthPipelineMqtt(); + UNITY_BEGIN(); printf("\n=== Group A: receive-side accept/reject ===\n"); @@ -787,8 +1865,19 @@ void setup() RUN_TEST(test_A7_unsigned_oversized_broadcast_from_signer_accepted); RUN_TEST(test_A8_unsigned_deadband_broadcast_from_signer_accepted); RUN_TEST(test_A9_unsigned_boundary_broadcast_from_signer_still_dropped); - RUN_TEST(test_A10_unsigned_broadcast_from_signer_with_unknown_fields_dropped); - RUN_TEST(test_A11_unsigned_broadcast_from_nonsigner_with_unknown_fields_accepted); + RUN_TEST(test_A10_compatible_accepts_unsigned_broadcast_from_signer); + RUN_TEST(test_A11_strict_rejects_unsigned_all_portnums_destinations_and_sizes); + RUN_TEST(test_A12_strict_rejects_signed_packet_without_key); + RUN_TEST(test_A13_strict_accepts_locally_authenticated_pki_packet); + RUN_TEST(test_A13b_strict_rejects_spoofed_pki_flag_on_encrypted_ingress); + RUN_TEST(test_A14_strict_bootstraps_identity_bound_signed_nodeinfo); + RUN_TEST(test_A15_strict_rejects_nodeinfo_key_without_identity_binding); + RUN_TEST(test_A16_compatible_rejects_invalid_first_contact_nodeinfo); +#if WARM_NODE_COUNT > 0 + RUN_TEST(test_A17_strict_verifies_signer_from_warm_key_store); +#endif + RUN_TEST(test_A18_unsigned_broadcast_from_signer_with_unknown_fields_dropped); + RUN_TEST(test_A19_unsigned_broadcast_from_nonsigner_with_unknown_fields_accepted); printf("\n=== Group B: send-side signing policy ===\n"); RUN_TEST(test_B1_local_broadcast_is_signed); @@ -797,12 +1886,41 @@ void setup() RUN_TEST(test_B4_all_broadcast_sizes_deliverable_no_deadband); RUN_TEST(test_B5_preset_signature_on_local_packet_cleared); RUN_TEST(test_B6_rich_shape_sweep_no_deadband); + RUN_TEST(test_B7_infrastructure_port_signing_matrix); + RUN_TEST(test_B8_licensed_broadcast_and_unicast_are_signed); + RUN_TEST(test_B9_licensed_unicast_never_uses_pki_encryption); + RUN_TEST(test_B10_licensed_oversized_unicast_remains_unsigned); + RUN_TEST(test_B11_normal_unicast_still_uses_pki); + RUN_TEST(test_B12_licensed_receiver_does_not_decrypt_pki); + RUN_TEST(test_B13_licensed_port_and_destination_signing_matrix); - printf("\n=== Group C: NodeInfoModule downgrade drop ===\n"); - RUN_TEST(test_C1_unsigned_nodeinfo_from_signer_dropped); - RUN_TEST(test_C2_signed_nodeinfo_from_signer_not_dropped); - RUN_TEST(test_C3_unsigned_nodeinfo_from_nonsigner_not_dropped); - RUN_TEST(test_C4_unsigned_unicast_nodeinfo_from_signer_accepted); + printf("\n=== Group C: routing pipeline authentication ordering ===\n"); + RUN_TEST(test_C1_invalid_first_copy_does_not_poison_valid_same_id); + RUN_TEST(test_C2_invalid_ordinary_duplicate_has_no_cancel_or_delivery_effects); + RUN_TEST(test_C3_invalid_repeated_packet_cannot_ack_or_change_retry_state); + RUN_TEST(test_C4_invalid_fallback_packet_cannot_relay); + RUN_TEST(test_C5_invalid_upgrade_cannot_remove_pending_valid_send); + RUN_TEST(test_C6_opaque_unknown_channel_is_relay_only); + RUN_TEST(test_C7_strict_rejects_unsigned_decoded_simradio_ingress); + RUN_TEST(test_C8_trusted_local_decoded_delivery_is_not_filtered); + RUN_TEST(test_C9_known_channel_malformed_plaintext_is_not_relayed_as_opaque); + RUN_TEST(test_C10_legacy_channel_dm_failure_has_no_pipeline_effects); + RUN_TEST(test_C11_malformed_pki_plaintext_has_no_pipeline_effects); + RUN_TEST(test_C12_exact_authenticated_replay_reuses_verdict_without_collision_bypass); + printf("\n=== Group N: NodeInfoModule authentication ===\n"); + RUN_TEST(test_N1_unsigned_nodeinfo_from_signer_dropped); + RUN_TEST(test_N2_signed_nodeinfo_from_signer_not_dropped); + RUN_TEST(test_N3_unsigned_nodeinfo_from_nonsigner_not_dropped); + RUN_TEST(test_N4_unsigned_unicast_nodeinfo_from_signer_accepted); + RUN_TEST(test_N5_unsigned_unicast_nodeinfo_from_signer_does_not_change_name); + RUN_TEST(test_N6_signed_unicast_nodeinfo_from_signer_changes_name); + RUN_TEST(test_N7_unsigned_unicast_nodeinfo_from_nonsigner_changes_name); + + printf("\n=== Group L: licensed identity and plaintext signing ===\n"); + RUN_TEST(test_L1_licensed_nodeinfo_publishes_public_key); + RUN_TEST(test_L2_licensed_identity_key_is_generated_and_preserved); + RUN_TEST(test_L3_factory_config_reset_preserves_valid_identity_private_key); + RUN_TEST(test_L4_licensed_low_entropy_identity_is_regenerated); printf("\n=== Group D: encoding invariants ===\n"); RUN_TEST(test_D1_signature_field_overhead_exact); @@ -814,11 +1932,17 @@ void setup() RUN_TEST(test_E4_decoded_bad_signature_dropped); RUN_TEST(test_E5_decoded_unsigned_oversized_broadcast_from_signer_accepted); RUN_TEST(test_E6_decoded_unsigned_unicast_from_signer_accepted); - RUN_TEST(test_E7_decoded_unsigned_pki_from_signer_accepted); RUN_TEST(test_E8_decoded_partial_signature_from_signer_dropped); RUN_TEST(test_E9_decoded_partial_signature_from_nonsigner_dropped); + RUN_TEST(test_E10_decoded_unsigned_position_padded_inside_payload_dropped); + RUN_TEST(test_E11_decoded_unsigned_oversized_telemetry_from_signer_accepted); + RUN_TEST(test_E12_decoded_unsigned_waypoint_padded_inside_payload_dropped); + RUN_TEST(test_E13_decoded_unsigned_nodeinfo_padded_inside_payload_dropped); - exit(UNITY_END()); + const int result = UNITY_END(); + airTime = savedAirTime; + nodeStatus = savedNodeStatus; + exit(result); } void loop() {} diff --git a/test/test_pki_admin_fallback/test_main.cpp b/test/test_pki_admin_fallback/test_main.cpp index c03652244..5c3b408c9 100644 --- a/test/test_pki_admin_fallback/test_main.cpp +++ b/test/test_pki_admin_fallback/test_main.cpp @@ -202,6 +202,62 @@ void test_wrong_admin_key_does_not_decode(void) TEST_ASSERT_NULL(mockNodeDB->getMeshNode(ADMIN_NODE)); } +// The fallback is budget-limited against flooding; see Router.cpp for why the budget is global. +void test_admin_key_fallback_is_rate_limited(void) +{ + // Start from a full bucket regardless of what earlier tests consumed (8 tokens, one per 250ms). + delay(2500); + + uint8_t otherPub[32], otherPriv[32]; + crypto->generateKeyPair(otherPub, otherPriv); + crypto->setDHPrivateKey(ourPriv); + setAdminKey(0, otherPub); // wrong key, so every attempt below fails and keeps its token spent + + // Drain the burst with undecryptable packets, as a flooding attacker would. + for (int i = 0; i < 8; i++) { + meshtastic_MeshPacket junk = makePkiPacket(ADMIN_NODE, meshtastic_PortNum_PRIVATE_APP, 16, adminPriv); + TEST_ASSERT_NOT_EQUAL(DECODE_SUCCESS, perhapsDecode(&junk)); + } + + // Budget exhausted: the fallback is skipped, so even a correct admin key does not decrypt. + setAdminKey(0, adminPub); + meshtastic_MeshPacket blocked = makePkiPacket(ADMIN_NODE, meshtastic_PortNum_PRIVATE_APP, 16, adminPriv); + TEST_ASSERT_NOT_EQUAL_MESSAGE(DECODE_SUCCESS, perhapsDecode(&blocked), "fallback should be budget-limited"); + + // The budget refills, so the throttle is not a permanent lockout. + delay(600); + meshtastic_MeshPacket allowed = makePkiPacket(ADMIN_NODE, meshtastic_PortNum_PRIVATE_APP, 16, adminPriv); + TEST_ASSERT_EQUAL_MESSAGE(DECODE_SUCCESS, perhapsDecode(&allowed), "budget should refill over time"); + assertDecodedAndLearned(&allowed, adminPub); +} + +// A pending key is an unverified identity claim from whoever opened a key-verification handshake, so it +// must decrypt only the exchange itself. Otherwise they could send DMs that look PKI-authenticated as a +// node they never proved they are. +void test_pending_key_decrypts_only_key_verification(void) +{ + // PEER is unknown to us; the handshake stashed its claimed key as pending. + static constexpr NodeNum PEER = 0x0C0C0C0C; + uint8_t peerPub[32], peerPriv[32]; + crypto->generateKeyPair(peerPub, peerPriv); + crypto->setDHPrivateKey(ourPriv); // generateKeyPair changed it + crypto->setPendingPublicKey(PEER, peerPub); + + // A DM on any other port must not decode, even though the pending key would decrypt it. + meshtastic_MeshPacket spoofed = makePkiPacket(PEER, meshtastic_PortNum_TEXT_MESSAGE_APP, 16, peerPriv); + TEST_ASSERT_NOT_EQUAL_MESSAGE(DECODE_SUCCESS, perhapsDecode(&spoofed), "pending key must not decrypt a text DM"); + TEST_ASSERT_FALSE_MESSAGE(spoofed.pki_encrypted, "spoofed DM must not be marked PKI-authenticated"); + + // The key-verification exchange itself still works, so bootstrapping is unaffected. + meshtastic_MeshPacket handshake = makePkiPacket(PEER, meshtastic_PortNum_KEY_VERIFICATION_APP, 16, peerPriv); + TEST_ASSERT_EQUAL_MESSAGE(DECODE_SUCCESS, perhapsDecode(&handshake), "key verification must still decrypt"); + TEST_ASSERT_TRUE(handshake.pki_encrypted); + + // A pending key is never persisted, so the peer stays unknown until verification commits it. + TEST_ASSERT_NULL_MESSAGE(mockNodeDB->getMeshNode(PEER), "pending key must not be learned into NodeDB"); + crypto->clearPendingPublicKey(); +} + #endif // !(MESHTASTIC_EXCLUDE_PKI) void setup() @@ -216,6 +272,8 @@ void setup() RUN_TEST(test_admin_key_slot2_only_decrypts); RUN_TEST(test_no_admin_key_unknown_sender_not_decoded); RUN_TEST(test_wrong_admin_key_does_not_decode); + RUN_TEST(test_admin_key_fallback_is_rate_limited); + RUN_TEST(test_pending_key_decrypts_only_key_verification); #endif exit(UNITY_END()); } diff --git a/test/test_radio/test_main.cpp b/test/test_radio/test_main.cpp index c62e6b267..df2442164 100644 --- a/test/test_radio/test_main.cpp +++ b/test/test_radio/test_main.cpp @@ -292,16 +292,30 @@ static void test_regionPresetMap_matchesRegionTable() const meshtastic_LoRaPresetGroup &grp = map.groups[gi]; const RegionInfo *r = getRegion(code); - // Group's list is non-empty, within the generated array bound, and is the - // region's full list. + // Group's list is non-empty and within the generated array bound. const size_t maxPresets = sizeof(grp.presets) / sizeof(grp.presets[0]); TEST_ASSERT_GREATER_THAN_UINT(0, grp.presets_count); TEST_ASSERT_LESS_OR_EQUAL_UINT((unsigned)maxPresets, grp.presets_count); - TEST_ASSERT_EQUAL_UINT((unsigned)r->getNumPresets(), (unsigned)grp.presets_count); - // Every advertised preset is legal in this region. - for (pb_size_t p = 0; p < grp.presets_count; p++) - TEST_ASSERT_TRUE(r->supportsPreset(grp.presets[p])); + // Every advertised preset must be selectable from this region: either legal here, + // or legal in a sibling the firmware will auto-swap us to (the EU 86x trio, which + // advertises the union of the trio's presets rather than just its own). + for (pb_size_t p = 0; p < grp.presets_count; p++) { + bool selectable = + r->supportsPreset(grp.presets[p]) || RadioInterface::regionSwapForPreset(code, grp.presets[p]) != nullptr; + TEST_ASSERT_TRUE(selectable); + } + + // The region's own enforced presets must all be advertised (advertised is a + // superset of the enforced list, never a subset). + const meshtastic_Config_LoRaConfig_ModemPreset *enforced = r->getAvailablePresets(); + for (size_t e = 0; e < r->getNumPresets(); e++) { + bool advertised = false; + for (pb_size_t p = 0; p < grp.presets_count; p++) + if (grp.presets[p] == enforced[e]) + advertised = true; + TEST_ASSERT_TRUE(advertised); + } // Default preset matches the table, is legal, and is present in the list. TEST_ASSERT_EQUAL(r->getDefaultPreset(), grp.default_preset); diff --git a/test/test_stream_api/test_main.cpp b/test/test_stream_api/test_main.cpp index 5a657d441..1fbd370a0 100644 --- a/test/test_stream_api/test_main.cpp +++ b/test/test_stream_api/test_main.cpp @@ -4,6 +4,7 @@ #include "configuration.h" #include "mesh-pb-constants.h" #include "mesh/MeshService.h" +#include "mesh/NodeDB.h" #include "mesh/StreamAPI.h" #include "mesh/StreamFrameWriter.h" #include @@ -136,6 +137,13 @@ class StreamAPITestShim : public StreamAPI } }; +/// Minimal PhoneAPI transport for config-stream tests. +class PhoneAPITestShim : public PhoneAPI +{ + protected: + bool checkIsConnected() override { return true; } +}; + /// Exposes framed-log hooks and records best-effort writes. class LogHookStreamAPI : public StreamAPI { @@ -475,6 +483,45 @@ static void test_lockdown_admin_gate_rejects_undecodable_admin(void) "undecodable ADMIN_APP payload must not pass through even when authorized"); } +static void test_want_config_includes_status_message_module_config(void) +{ + ScopedMeshService scopedService; + NodeDB testNodeDB; + NodeDB *const savedNodeDB = nodeDB; + nodeDB = &testNodeDB; + const auto savedModuleConfig = moduleConfig; + moduleConfig.has_statusmessage = true; + strncpy(moduleConfig.statusmessage.node_status, "Ready", sizeof(moduleConfig.statusmessage.node_status) - 1); + moduleConfig.statusmessage.node_status[sizeof(moduleConfig.statusmessage.node_status) - 1] = '\0'; + + meshtastic_ToRadio request = meshtastic_ToRadio_init_zero; + request.which_payload_variant = meshtastic_ToRadio_want_config_id_tag; + request.want_config_id = SPECIAL_NONCE_ONLY_CONFIG; + uint8_t requestBytes[meshtastic_ToRadio_size]; + const size_t requestSize = pb_encode_to_bytes(requestBytes, sizeof(requestBytes), &meshtastic_ToRadio_msg, &request); + + PhoneAPITestShim api; + api.handleToRadio(requestBytes, requestSize); + + bool foundStatusMessageConfig = false; + for (unsigned i = 0; i < 64 && !foundStatusMessageConfig; ++i) { + uint8_t responseBytes[meshtastic_FromRadio_size]; + const size_t responseSize = api.getFromRadio(responseBytes); + meshtastic_FromRadio response = meshtastic_FromRadio_init_zero; + TEST_ASSERT_TRUE(pb_decode_from_bytes(responseBytes, responseSize, &meshtastic_FromRadio_msg, &response)); + if (response.which_payload_variant == meshtastic_FromRadio_moduleConfig_tag && + response.moduleConfig.which_payload_variant == meshtastic_ModuleConfig_statusmessage_tag) { + foundStatusMessageConfig = true; + TEST_ASSERT_EQUAL_STRING("Ready", response.moduleConfig.payload_variant.statusmessage.node_status); + } + } + + api.close(); + moduleConfig = savedModuleConfig; + nodeDB = savedNodeDB; + TEST_ASSERT_TRUE(foundStatusMessageConfig); +} + /// Unity per-test setup; fixtures are local to each test. void setUp(void) {} /// Unity per-test teardown; fixtures clean themselves up. @@ -496,6 +543,7 @@ void setup() RUN_TEST(test_stream_api_gates_logs_and_marks_them_best_effort); RUN_TEST(test_lockdown_admin_gate_ignores_wire_from); RUN_TEST(test_lockdown_admin_gate_rejects_undecodable_admin); + RUN_TEST(test_want_config_includes_status_message_module_config); // usingProtobufs intentionally has no reset path, so this must run last. RUN_TEST(test_serial_console_suppresses_raw_output_in_protobuf_mode); exit(UNITY_END()); diff --git a/test/test_tak_config/test_main.cpp b/test/test_tak_config/test_main.cpp new file mode 100644 index 000000000..672237376 --- /dev/null +++ b/test/test_tak_config/test_main.cpp @@ -0,0 +1,154 @@ +// TAK (ATAK) team/role value fidelity: the fields set by the app must come back verbatim. +// +// Companion to test_module_config, which proves every submessage's presence survives the +// set -> save -> load -> get lifecycle but sweeps with zero-valued payloads and so cannot tell a +// copied field from an ignored one. These tests pin the values themselves for the submessage that +// shipped broken (firmware#11182 / Meshtastic-Android#6430: team/role reverted to "unspecified" +// after every save). + +#include "MeshTypes.h" // include BEFORE TestUtil.h +#include "TestUtil.h" +#include + +#include "mesh/NodeDB.h" +#include "mesh/mesh-pb-constants.h" +#include "modules/AdminModule.h" +#include "support/AdminModuleTestShim.h" +#include "support/MockMeshService.h" +#include + +static constexpr NodeNum LOCAL_NODE = 0x0A0A0A0A; +static constexpr NodeNum ADMIN_NODE = 0x0B0B0B0B; + +static MockMeshService *mockService = nullptr; +static AdminModuleTestShim *admin = nullptr; + +// A set_module_config payload carrying the tak submessage, as the phone app sends it. +static meshtastic_ModuleConfig makeTakModuleConfig(meshtastic_Team team, meshtastic_MemberRole role) +{ + meshtastic_ModuleConfig mc = meshtastic_ModuleConfig_init_zero; + mc.which_payload_variant = meshtastic_ModuleConfig_tak_tag; + mc.payload_variant.tak.team = team; + mc.payload_variant.tak.role = role; + return mc; +} + +static meshtastic_MeshPacket makeGetRequest(NodeNum from) +{ + meshtastic_MeshPacket req = meshtastic_MeshPacket_init_zero; + req.from = from; + req.which_payload_variant = meshtastic_MeshPacket_decoded_tag; + req.decoded.want_response = true; + return req; +} + +// Pull the TAKConfig out of the get_module_config response a handler queued in myReply. +static bool decodeTakFromReply(meshtastic_MeshPacket *reply, meshtastic_ModuleConfig_TAKConfig &out) +{ + meshtastic_AdminMessage am = meshtastic_AdminMessage_init_zero; + if (!reply || reply->which_payload_variant != meshtastic_MeshPacket_decoded_tag) + return false; + if (!pb_decode_from_bytes(reply->decoded.payload.bytes, reply->decoded.payload.size, &meshtastic_AdminMessage_msg, &am)) + return false; + if (am.which_payload_variant != meshtastic_AdminMessage_get_module_config_response_tag || + am.get_module_config_response.which_payload_variant != meshtastic_ModuleConfig_tak_tag) + return false; + out = am.get_module_config_response.payload_variant.tak; + return true; +} + +void test_setStoresTeamAndRoleVerbatim(void) +{ + TEST_ASSERT_TRUE(admin->handleSetModuleConfig(makeTakModuleConfig(meshtastic_Team_Cyan, meshtastic_MemberRole_Medic))); + + TEST_ASSERT_TRUE(moduleConfig.has_tak); + TEST_ASSERT_EQUAL(meshtastic_Team_Cyan, moduleConfig.tak.team); + TEST_ASSERT_EQUAL(meshtastic_MemberRole_Medic, moduleConfig.tak.role); +} + +// Clearing back to the zero values is a real edit, not an absent field: has_tak must stay set so the +// cleared state is what gets written, rather than the previous value surviving. +void test_clearingToUnspecifiedIsStored(void) +{ + admin->handleSetModuleConfig(makeTakModuleConfig(meshtastic_Team_Red, meshtastic_MemberRole_TeamLead)); + admin->handleSetModuleConfig(makeTakModuleConfig(meshtastic_Team_Unspecifed_Color, meshtastic_MemberRole_Unspecifed)); + + TEST_ASSERT_TRUE(moduleConfig.has_tak); + TEST_ASSERT_EQUAL(meshtastic_Team_Unspecifed_Color, moduleConfig.tak.team); + TEST_ASSERT_EQUAL(meshtastic_MemberRole_Unspecifed, moduleConfig.tak.role); +} + +// The reported symptom end to end: values set, saved, reloaded (as a reboot would), and read back. +void test_valuesSurviveSaveLoad(void) +{ + admin->handleSetModuleConfig(makeTakModuleConfig(meshtastic_Team_Magenta, meshtastic_MemberRole_Sniper)); + + uint8_t buf[meshtastic_LocalModuleConfig_size]; + size_t len = pb_encode_to_bytes(buf, sizeof(buf), &meshtastic_LocalModuleConfig_msg, &moduleConfig); + + moduleConfig = meshtastic_LocalModuleConfig_init_zero; + TEST_ASSERT_TRUE(pb_decode_from_bytes(buf, len, &meshtastic_LocalModuleConfig_msg, &moduleConfig)); + + TEST_ASSERT_TRUE(moduleConfig.has_tak); + TEST_ASSERT_EQUAL(meshtastic_Team_Magenta, moduleConfig.tak.team); + TEST_ASSERT_EQUAL(meshtastic_MemberRole_Sniper, moduleConfig.tak.role); +} + +// Remote and local admin reads take the same branch; both must carry the stored values unredacted. +void test_getReturnsStoredValues(void) +{ + admin->handleSetModuleConfig(makeTakModuleConfig(meshtastic_Team_Dark_Blue, meshtastic_MemberRole_K9)); + + const NodeNum requesters[] = {ADMIN_NODE, 0}; + for (NodeNum from : requesters) { + meshtastic_MeshPacket req = makeGetRequest(from); + admin->handleGetModuleConfig(req, meshtastic_AdminMessage_ModuleConfigType_TAK_CONFIG); + + meshtastic_ModuleConfig_TAKConfig tak; + TEST_ASSERT_TRUE_MESSAGE(decodeTakFromReply(admin->reply(), tak), "TAK_CONFIG request must produce a tak response"); + TEST_ASSERT_EQUAL(meshtastic_Team_Dark_Blue, tak.team); + TEST_ASSERT_EQUAL(meshtastic_MemberRole_K9, tak.role); + admin->drainReply(); + } +} + +void setUp(void) +{ + mockService = new MockMeshService(); + service = mockService; + + admin = new AdminModuleTestShim(); + admin->deferSaves(); // no disk write or reboot when a setter is accepted + + moduleConfig = meshtastic_LocalModuleConfig_init_zero; + config = meshtastic_LocalConfig_init_zero; + myNodeInfo = meshtastic_MyNodeInfo_init_zero; + myNodeInfo.my_node_num = LOCAL_NODE; +} + +void tearDown(void) +{ + admin->drainReply(); + delete admin; + admin = nullptr; + + service = nullptr; + delete mockService; + mockService = nullptr; +} + +void setup() +{ + delay(10); + initializeTestEnvironment(); + UNITY_BEGIN(); + + RUN_TEST(test_setStoresTeamAndRoleVerbatim); + RUN_TEST(test_clearingToUnspecifiedIsStored); + RUN_TEST(test_valuesSurviveSaveLoad); + RUN_TEST(test_getReturnsStoredValues); + + exit(UNITY_END()); +} + +void loop() {} diff --git a/test/test_traceroute_nexthop/test_main.cpp b/test/test_traceroute_nexthop/test_main.cpp new file mode 100644 index 000000000..5f8d546db --- /dev/null +++ b/test/test_traceroute_nexthop/test_main.cpp @@ -0,0 +1,162 @@ +// Tests that TraceRouteModule only learns next_hop from a traceroute response when the route array +// agrees with the node that actually relayed the packet. The route is unauthenticated payload, so +// without that check a forged response could point any node's next_hop anywhere. + +#include "MeshTypes.h" // include BEFORE TestUtil.h +#include "TestUtil.h" +#include + +#include "mesh/NodeDB.h" +#include "modules/TraceRouteModule.h" +#include + +static constexpr NodeNum LOCAL_NODE = 0x0A0A0A0A; // us, the node that asked for the traceroute +static constexpr NodeNum RELAY_B = 0x0B0B0B0B; // the honest first hop back towards us +static constexpr NodeNum NODE_C = 0x0C0C0C0C; +static constexpr NodeNum NODE_D = 0x0D0D0D0D; // the traceroute target, which answers +static constexpr uint8_t ATTACKER_RELAY_BYTE = 0xEE; // some node that is not RELAY_B + +class MockNodeDB : public NodeDB +{ + public: + void clearTestNodes() + { + testNodes.clear(); + meshNodes = &testNodes; + numMeshNodes = 0; + } + + void addNode(NodeNum num) + { + meshtastic_NodeInfoLite node = meshtastic_NodeInfoLite_init_zero; + node.num = num; + testNodes.push_back(node); + meshNodes = &testNodes; + numMeshNodes = testNodes.size(); + } + + uint8_t nextHopOf(NodeNum num) + { + meshtastic_NodeInfoLite *n = getMeshNode(num); + TEST_ASSERT_NOT_NULL(n); + return n->next_hop; + } + + std::vector testNodes; +}; + +// alterReceivedProtobuf is the real entry point; updateNextHops is private behind it. +class TraceRouteModuleTestShim : public TraceRouteModule +{ + public: + using TraceRouteModule::alterReceivedProtobuf; +}; + +static MockNodeDB *mockNodeDB = nullptr; +static TraceRouteModuleTestShim *shim = nullptr; + +// A traceroute response addressed to us, claiming the forward route LOCAL -> RELAY_B -> NODE_C -> NODE_D, +// carried to us by whichever node `relayByte` names. +static meshtastic_MeshPacket makeResponse(uint8_t relayByte, meshtastic_RouteDiscovery *r) +{ + *r = meshtastic_RouteDiscovery_init_zero; + r->route_count = 3; + r->route[0] = RELAY_B; + r->route[1] = NODE_C; + r->route[2] = NODE_D; + + meshtastic_MeshPacket p = meshtastic_MeshPacket_init_zero; + p.from = NODE_D; // the target answered + p.to = LOCAL_NODE; + p.id = 0x1234; + p.relay_node = relayByte; + p.hop_start = 3; + p.hop_limit = 1; + p.which_payload_variant = meshtastic_MeshPacket_decoded_tag; + p.decoded.portnum = meshtastic_PortNum_TRACEROUTE_APP; + p.decoded.request_id = 0x9999; // non-zero marks this a response, which is what drives updateNextHops + return p; +} + +void setUp(void) +{ + mockNodeDB = new MockNodeDB(); + mockNodeDB->clearTestNodes(); + nodeDB = mockNodeDB; + + config = meshtastic_LocalConfig_init_zero; + owner = meshtastic_User_init_zero; + myNodeInfo.my_node_num = LOCAL_NODE; // drives isToUs() + + mockNodeDB->addNode(LOCAL_NODE); + mockNodeDB->addNode(RELAY_B); + mockNodeDB->addNode(NODE_C); + mockNodeDB->addNode(NODE_D); + + shim = new TraceRouteModuleTestShim(); +} + +void tearDown(void) +{ + delete shim; + shim = nullptr; + delete mockNodeDB; + mockNodeDB = nullptr; + nodeDB = nullptr; +} + +// The honest case: the route names RELAY_B as our next hop, and RELAY_B is who handed us the packet. +void test_nexthop_learned_when_route_matches_relay(void) +{ + meshtastic_RouteDiscovery r; + meshtastic_MeshPacket p = makeResponse(nodeDB->getLastByteOfNodeNum(RELAY_B), &r); + + shim->alterReceivedProtobuf(p, &r); + + const uint8_t expected = nodeDB->getLastByteOfNodeNum(RELAY_B); + TEST_ASSERT_EQUAL_MESSAGE(expected, mockNodeDB->nextHopOf(RELAY_B), "next hop for the relay itself"); + TEST_ASSERT_EQUAL_MESSAGE(expected, mockNodeDB->nextHopOf(NODE_C), "next hop for a node beyond the relay"); + TEST_ASSERT_EQUAL_MESSAGE(expected, mockNodeDB->nextHopOf(NODE_D), "next hop for the target"); +} + +// A forged response: the attacker transmits it themselves, so relay_node is their byte, but the route +// claims RELAY_B. Believing the payload here would let them redirect traffic for every node listed. +void test_nexthop_ignored_when_route_contradicts_relay(void) +{ + meshtastic_RouteDiscovery r; + meshtastic_MeshPacket p = makeResponse(ATTACKER_RELAY_BYTE, &r); + + shim->alterReceivedProtobuf(p, &r); + + TEST_ASSERT_EQUAL_MESSAGE(0, mockNodeDB->nextHopOf(RELAY_B), "forged route must not set a next hop"); + TEST_ASSERT_EQUAL_MESSAGE(0, mockNodeDB->nextHopOf(NODE_C), "forged route must not set a next hop"); + TEST_ASSERT_EQUAL_MESSAGE(0, mockNodeDB->nextHopOf(NODE_D), "forged route must not set a next hop"); +} + +// MQTT-sourced packets carry relay_node 0, so nothing corroborates the route and we must not learn. +void test_nexthop_ignored_without_a_relay(void) +{ + meshtastic_RouteDiscovery r; + meshtastic_MeshPacket p = makeResponse(NO_RELAY_NODE, &r); + + shim->alterReceivedProtobuf(p, &r); + + TEST_ASSERT_EQUAL_MESSAGE(0, mockNodeDB->nextHopOf(RELAY_B), "no relay means no corroboration"); + TEST_ASSERT_EQUAL_MESSAGE(0, mockNodeDB->nextHopOf(NODE_C), "no relay means no corroboration"); + TEST_ASSERT_EQUAL_MESSAGE(0, mockNodeDB->nextHopOf(NODE_D), "no relay means no corroboration"); +} + +void setup() +{ + delay(10); + delay(2000); + + initializeTestEnvironment(); + UNITY_BEGIN(); + RUN_TEST(test_nexthop_learned_when_route_matches_relay); + RUN_TEST(test_nexthop_ignored_when_route_contradicts_relay); + RUN_TEST(test_nexthop_ignored_without_a_relay); + exit(UNITY_END()); +} + +void loop() {} diff --git a/test/test_traffic_management/test_main.cpp b/test/test_traffic_management/test_main.cpp index f64ea814f..afdcb9646 100644 --- a/test/test_traffic_management/test_main.cpp +++ b/test/test_traffic_management/test_main.cpp @@ -12,6 +12,7 @@ #if HAS_TRAFFIC_MANAGEMENT #include "airtime.h" +#include "gps/RTC.h" #include "mesh/CryptoEngine.h" #include "mesh/Default.h" #include "mesh/MeshService.h" @@ -31,6 +32,10 @@ namespace constexpr NodeNum kLocalNode = 0x11111111; constexpr NodeNum kRemoteNode = 0x22222222; constexpr NodeNum kTargetNode = 0x33333333; +// A second, distinct requester. The per-requester direct-response throttle (60 s) is keyed on the +// requesting packet's `from`, so tests that exercise the per-target / fallback / sweep throttles use +// a fresh requester for their "served again" step to avoid the per-requester window masking them. +constexpr NodeNum kRemoteNode2 = 0x44444444; // Telemetry hop exhaustion is gated on channel congestion (alterReceived checks // airTime->isTxAllowedChannelUtil/isTxAllowedAirUtil). Installs a global @@ -102,6 +107,21 @@ class MockNodeDB : public NodeDB numMeshNodes = 2; } + // Seed a full identity (name, 32-byte key of `keyByte`, optional signer bit) into the + // hot-store buffer at index 1, for reconcile/seeding tests that iterate + // getMeshNodeByIndex(). + void setHotNodeIdentity(NodeNum n, const char *longName, uint8_t keyByte, bool signer) + { + setHotNode(n, 0); + meshtastic_NodeInfoLite &info = (*meshNodes)[1]; + strncpy(info.long_name, longName, sizeof(info.long_name) - 1); + info.public_key.size = 32; + memset(info.public_key.bytes, keyByte, 32); + info.bitfield |= NODEINFO_BITFIELD_HAS_USER_MASK; + if (signer) + info.bitfield |= NODEINFO_BITFIELD_HAS_XEDDSA_SIGNED_MASK; + } + // Evict everything but "self" - simulates the hot DB rolling over. Logical // count only; the buffer is left intact so the invariant holds. void rollHotStore() @@ -173,9 +193,13 @@ class TrafficManagementModuleTestShim : public TrafficManagementModule { public: using TrafficManagementModule::alterReceived; + using TrafficManagementModule::dropNodeInfoCacheForTest; using TrafficManagementModule::flushCache; using TrafficManagementModule::handleReceived; + using TrafficManagementModule::markKeySignerProvenForTest; + using TrafficManagementModule::nodeInfoCacheCapacityForTest; using TrafficManagementModule::peekCachedRole; + using TrafficManagementModule::peekNodeInfoFlagsForTest; using TrafficManagementModule::runOnce; bool ignoreRequestFlag() const { return ignoreRequest; } @@ -345,6 +369,14 @@ static void test_tm_moduleDisabled_doesNothing(void) TEST_ASSERT_EQUAL_UINT32(0, stats.packets_inspected); TEST_ASSERT_EQUAL_UINT32(0, stats.unknown_packet_drops); TEST_ASSERT_FALSE(module.ignoreRequestFlag()); + + // The write-through hooks share the disabled gate: with maintenance (sweep + reconcile) + // off, NodeDB commits must not fill the NodeInfo cache either. + uint8_t key[32]; + memset(key, 0x42, sizeof(key)); + module.onNodeKeyCommitted(kRemoteNode, key, false); + uint8_t out[32]; + TEST_ASSERT_FALSE(module.copyPublicKey(kRemoteNode, out, nullptr)); } /** @@ -534,6 +566,8 @@ static void test_tm_nodeinfo_directResponse_respondsFromCache(void) config.device.role = meshtastic_Config_DeviceConfig_Role_CLIENT; config.lora.config_ok_to_mqtt = true; mockNodeDB->setCachedNode(kTargetNode); + // Signed-only replay gate (default) requires the target be a known signer to be served. + mockNodeDB->cachedNodeForTest().bitfield |= NODEINFO_BITFIELD_HAS_XEDDSA_SIGNED_MASK; MockRouter mockRouter; mockRouter.addInterface(std::unique_ptr(new MockRadioInterface())); @@ -542,6 +576,7 @@ static void test_tm_nodeinfo_directResponse_respondsFromCache(void) service = &mockService; TrafficManagementModuleTestShim module; + module.dropNodeInfoCacheForTest(); // exercise the NodeDB fallback path meshtastic_MeshPacket request = makeDecodedPacket(meshtastic_PortNum_NODEINFO_APP, kRemoteNode, kTargetNode); request.decoded.want_response = true; request.id = 0x13572468; @@ -578,6 +613,8 @@ static void test_tm_nodeinfo_directResponse_learnsRequestorNodeInfo(void) moduleConfig.traffic_management.nodeinfo_direct_response_max_hops = 10; config.device.role = meshtastic_Config_DeviceConfig_Role_CLIENT; mockNodeDB->setCachedNode(kTargetNode); + // Signed-only replay gate (default) requires the target be a known signer to be served. + mockNodeDB->cachedNodeForTest().bitfield |= NODEINFO_BITFIELD_HAS_XEDDSA_SIGNED_MASK; MockRouter mockRouter; mockRouter.addInterface(std::unique_ptr(new MockRadioInterface())); @@ -586,6 +623,7 @@ static void test_tm_nodeinfo_directResponse_learnsRequestorNodeInfo(void) service = &mockService; TrafficManagementModuleTestShim module; + module.dropNodeInfoCacheForTest(); // exercise the NodeDB fallback path meshtastic_MeshPacket request = makeNodeInfoPacket(kRemoteNode, "requester-long", "rq"); request.to = kTargetNode; request.decoded.want_response = true; @@ -614,7 +652,9 @@ static void test_tm_nodeinfo_directResponse_ignoresUnsignedSignerIdentity(void) { moduleConfig.traffic_management.nodeinfo_direct_response_max_hops = 10; config.device.role = meshtastic_Config_DeviceConfig_Role_CLIENT; - mockNodeDB->setCachedNode(kTargetNode); // the direct-response target + mockNodeDB->setCachedNode(kTargetNode); // the direct-response target + // Signed-only replay gate (default) requires the target be a known signer to be served. + mockNodeDB->cachedNodeForTest().bitfield |= NODEINFO_BITFIELD_HAS_XEDDSA_SIGNED_MASK; mockNodeDB->setSignerHotNode(kRemoteNode, "victim-real"); // the requester is a known signer MockRouter mockRouter; @@ -624,6 +664,7 @@ static void test_tm_nodeinfo_directResponse_ignoresUnsignedSignerIdentity(void) service = &mockService; TrafficManagementModuleTestShim module; + module.dropNodeInfoCacheForTest(); // exercise the NodeDB fallback path this test was written for meshtastic_MeshPacket request = makeNodeInfoPacket(kRemoteNode, "attacker-name", "atk"); request.to = kTargetNode; request.decoded.want_response = true; @@ -667,11 +708,10 @@ static void test_tm_nodeinfo_clientClamp_skipsWhenNotDirect(void) TEST_ASSERT_FALSE(module.ignoreRequestFlag()); } -#if !(defined(ARCH_ESP32) && defined(BOARD_HAS_PSRAM)) /** - * Verify non-PSRAM builds require NodeDB for direct NodeInfo responses. + * Verify the NodeDB-fallback path requires NodeDB for direct NodeInfo responses. * Important because fallback should only happen through node-wide data when - * the dedicated PSRAM cache does not exist. + * the dedicated NodeInfo cache does not exist. */ static void test_tm_nodeinfo_directResponse_withoutNodeDbEntry_skips(void) { @@ -686,6 +726,7 @@ static void test_tm_nodeinfo_directResponse_withoutNodeDbEntry_skips(void) service = &mockService; TrafficManagementModuleTestShim module; + module.dropNodeInfoCacheForTest(); // exercise the NodeDB fallback path meshtastic_MeshPacket request = makeDecodedPacket(meshtastic_PortNum_NODEINFO_APP, kRemoteNode, kTargetNode); request.decoded.want_response = true; request.hop_start = 3; @@ -699,11 +740,10 @@ static void test_tm_nodeinfo_directResponse_withoutNodeDbEntry_skips(void) TEST_ASSERT_EQUAL_UINT32(0, stats.nodeinfo_cache_hits); TEST_ASSERT_EQUAL_UINT32(0, static_cast(mockRouter.sentPackets.size())); } -#endif -#if defined(ARCH_ESP32) && defined(BOARD_HAS_PSRAM) +#if TMM_HAS_NODEINFO_CACHE /** - * Verify PSRAM NodeInfo cache can answer requests without NodeDB and that + * Verify the NodeInfo cache can answer requests without NodeDB and that * shouldRespondToNodeInfo() uses cached bitfield metadata. */ static void test_tm_nodeinfo_directResponse_psramCacheRespondsAndPreservesBitfield(void) @@ -729,6 +769,8 @@ static void test_tm_nodeinfo_directResponse_psramCacheRespondsAndPreservesBitfie ProcessMessage observedResult = module.handleReceived(observed); TEST_ASSERT_EQUAL_INT(static_cast(ProcessMessage::CONTINUE), static_cast(observedResult)); + // Signed-only replay gate (default) requires signer-proven provenance to serve. + module.markKeySignerProvenForTest(kTargetNode); meshtastic_MeshPacket request = makeDecodedPacket(meshtastic_PortNum_NODEINFO_APP, kRemoteNode, kTargetNode); request.decoded.want_response = true; @@ -755,9 +797,9 @@ static void test_tm_nodeinfo_directResponse_psramCacheRespondsAndPreservesBitfie } /** - * Verify PSRAM cache misses do not fall back to NodeDB. - * Important so the dedicated PSRAM index stays logically separate from - * NodeInfoModule/NodeDB when PSRAM is available. + * Verify NodeInfo cache misses do not fall back to NodeDB. + * Important so the dedicated cache index stays logically separate from + * NodeInfoModule/NodeDB when the cache is available. */ static void test_tm_nodeinfo_directResponse_psramMissDoesNotFallbackToNodeDb(void) { @@ -785,8 +827,1395 @@ static void test_tm_nodeinfo_directResponse_psramMissDoesNotFallbackToNodeDb(voi TEST_ASSERT_EQUAL_UINT32(0, stats.nodeinfo_cache_hits); TEST_ASSERT_EQUAL_UINT32(0, static_cast(mockRouter.sentPackets.size())); } + +/** + * Verify a cached NodeInfo is NOT served once it ages past the serve window. + * Important: without this gate a long-gone (or forged) node's cached NodeInfo would be + * spoofed back to requestors indefinitely while the genuine request is suppressed. + */ +static void test_tm_nodeinfo_directResponse_psramStaleEntryNotServed(void) +{ + moduleConfig.traffic_management.nodeinfo_direct_response_max_hops = 10; + config.device.role = meshtastic_Config_DeviceConfig_Role_CLIENT; + mockNodeDB->clearCachedNode(); + + MockRouter mockRouter; + mockRouter.addInterface(std::unique_ptr(new MockRadioInterface())); + MeshService mockService; + router = &mockRouter; + service = &mockService; + + TrafficManagementModuleTestShim module; + + // Learn a NodeInfo for the target into the NodeInfo cache (broadcast, so it is only cached). + meshtastic_MeshPacket observed = makeNodeInfoPacket(kTargetNode, "target-long", "tg"); + module.handleReceived(observed); + // Signer-proven so staleness is the sole reason it is not served (isolates the gate under test). + module.markKeySignerProvenForTest(kTargetNode); + + // Advance the virtual clock just past the 6 h serve window. + // 6 h + two 3-min observation ticks: guarantees the modular obs-tick age exceeds the + // 120-tick serve window whatever the clock's offset within its current tick. + TrafficManagementModule::s_testNowMs += (6UL * 60UL * 60UL * 1000UL) + (2UL * 3UL * 60UL * 1000UL); + + meshtastic_MeshPacket request = makeDecodedPacket(meshtastic_PortNum_NODEINFO_APP, kRemoteNode, kTargetNode); + request.decoded.want_response = true; + request.hop_start = 3; + request.hop_limit = 3; + + ProcessMessage result = module.handleReceived(request); + meshtastic_TrafficManagementStats stats = module.getStats(); + + TEST_ASSERT_EQUAL_INT(static_cast(ProcessMessage::CONTINUE), static_cast(result)); + TEST_ASSERT_EQUAL_UINT32(0, stats.nodeinfo_cache_hits); + TEST_ASSERT_EQUAL_UINT32(0, static_cast(mockRouter.sentPackets.size())); +} + +/** + * Per-target direct-response throttle on the PSRAM cache path: repeated NodeInfo requests for the + * same target yield one spoofed reply per 60 s window - even from a DIFFERENT requester, since the + * target axis is independent of the requester axis - then a reply is allowed again once it elapses. + */ +static void test_tm_nodeinfo_directResponse_psramThrottlesWithinWindow(void) +{ + moduleConfig.traffic_management.nodeinfo_direct_response_max_hops = 10; + config.device.role = meshtastic_Config_DeviceConfig_Role_CLIENT; + mockNodeDB->clearCachedNode(); + + MockRouter mockRouter; + mockRouter.addInterface(std::unique_ptr(new MockRadioInterface())); + MeshService mockService; + router = &mockRouter; + service = &mockService; + + TrafficManagementModuleTestShim module; + + meshtastic_MeshPacket observed = makeNodeInfoPacket(kTargetNode, "target-long", "tg"); + module.handleReceived(observed); + // Signed-only replay gate (default) requires signer-proven provenance to serve. + module.markKeySignerProvenForTest(kTargetNode); + + meshtastic_MeshPacket request = makeDecodedPacket(meshtastic_PortNum_NODEINFO_APP, kRemoteNode, kTargetNode); + request.decoded.want_response = true; + request.hop_start = 3; + request.hop_limit = 3; + + // First request: served. + request.id = 0xAAAA0001; + TEST_ASSERT_EQUAL_INT(static_cast(ProcessMessage::STOP), static_cast(module.handleReceived(request))); + TEST_ASSERT_EQUAL_UINT32(1, static_cast(mockRouter.sentPackets.size())); + + // Second request within the window, from a DIFFERENT requester so only the per-target axis can + // throttle it: our spoofed TX is suppressed, but the request is NOT consumed - it CONTINUEs into + // normal relay handling so the genuine target can answer itself. (Previously it was black-holed: + // STOPped with nothing sent.) The cache-hit stat counts only replies actually sent. + TrafficManagementModule::s_testNowMs += 5000; // 5 s < 60 s window + request.from = kRemoteNode2; + request.id = 0xAAAA0002; + TEST_ASSERT_EQUAL_INT(static_cast(ProcessMessage::CONTINUE), static_cast(module.handleReceived(request))); + TEST_ASSERT_FALSE(module.ignoreRequestFlag()); + TEST_ASSERT_EQUAL_UINT32(1, static_cast(mockRouter.sentPackets.size())); + TEST_ASSERT_EQUAL_UINT32(1, module.getStats().nodeinfo_cache_hits); + + // Past the per-target window: served again. Back to the original requester, whose own 60 s + // per-requester window from the first reply has also elapsed, so only the per-target release is + // under test here. + TrafficManagementModule::s_testNowMs += 60000; // now > 60 s since first reply + request.from = kRemoteNode; + request.id = 0xAAAA0003; + TEST_ASSERT_EQUAL_INT(static_cast(ProcessMessage::STOP), static_cast(module.handleReceived(request))); + TEST_ASSERT_EQUAL_UINT32(2, static_cast(mockRouter.sentPackets.size())); +} + +#if !(MESHTASTIC_EXCLUDE_PKI) +// Build a NODEINFO_APP broadcast whose User carries a 32-byte public key of `keyByte`. +static meshtastic_MeshPacket makeNodeInfoPacketWithKey(NodeNum from, const char *longName, uint8_t keyByte) +{ + meshtastic_MeshPacket packet = makeDecodedPacket(meshtastic_PortNum_NODEINFO_APP, from, NODENUM_BROADCAST); + meshtastic_User user = meshtastic_User_init_zero; + snprintf(user.id, sizeof(user.id), "!%08x", from); + strncpy(user.long_name, longName, sizeof(user.long_name) - 1); + user.public_key.size = 32; + memset(user.public_key.bytes, keyByte, 32); + packet.decoded.payload.size = + pb_encode_to_bytes(packet.decoded.payload.bytes, sizeof(packet.decoded.payload.bytes), &meshtastic_User_msg, &user); + return packet; +} + +/** + * Verify the NodeInfo cache pins the first-seen public key: a later NodeInfo for the same + * node carrying a DIFFERENT key is rejected, and the served reply keeps the original key. + * Mirrors NodeDB::updateUser()'s "Public Key mismatch, dropping NodeInfo" protection. + */ +static void test_tm_nodeinfo_cache_rejectsMismatchedKey(void) +{ + moduleConfig.traffic_management.nodeinfo_direct_response_max_hops = 10; + config.device.role = meshtastic_Config_DeviceConfig_Role_CLIENT; + mockNodeDB->clearCachedNode(); // no authoritative NodeDB key -> exercise the cache's own TOFU pin + + MockRouter mockRouter; + mockRouter.addInterface(std::unique_ptr(new MockRadioInterface())); + MeshService mockService; + router = &mockRouter; + service = &mockService; + + TrafficManagementModuleTestShim module; + + // First-seen key (0x11...) is pinned. + module.handleReceived(makeNodeInfoPacketWithKey(kTargetNode, "genuine", 0x11)); + // Poisoning attempt with a different key (0x22...) must be rejected. + module.handleReceived(makeNodeInfoPacketWithKey(kTargetNode, "attacker", 0x22)); + // Signed-only replay gate (default) requires signer-proven provenance to serve the reply. + module.markKeySignerProvenForTest(kTargetNode); + + meshtastic_MeshPacket request = makeDecodedPacket(meshtastic_PortNum_NODEINFO_APP, kRemoteNode, kTargetNode); + request.decoded.want_response = true; + request.hop_start = 3; + request.hop_limit = 3; + TEST_ASSERT_EQUAL_INT(static_cast(ProcessMessage::STOP), static_cast(module.handleReceived(request))); + TEST_ASSERT_EQUAL_UINT32(1, static_cast(mockRouter.sentPackets.size())); + + // The served reply must still carry the original (pinned) key, not the attacker's. + meshtastic_User served = meshtastic_User_init_zero; + const meshtastic_MeshPacket &reply = mockRouter.sentPackets.front(); + TEST_ASSERT_TRUE( + pb_decode_from_bytes(reply.decoded.payload.bytes, reply.decoded.payload.size, &meshtastic_User_msg, &served)); + TEST_ASSERT_EQUAL_UINT32(32, served.public_key.size); + TEST_ASSERT_EQUAL_UINT8(0x11, served.public_key.bytes[0]); + TEST_ASSERT_EQUAL_UINT8(0x11, served.public_key.bytes[31]); +} + +#if WARM_NODE_COUNT > 0 +/** + * Verify the NodeInfo cache pin also covers the WARM tier: for a node evicted from the hot + * store whose key lives only in the warm tier (and whose cache slot is empty), a NodeInfo + * carrying a different key must be rejected, and one carrying the warm key accepted. + * Important: with a hot-store-only pin, an attacker could seed this cache with a bogus key + * for a warm-evicted node; the cache's own TOFU pin would then lock the genuine node's + * frames out until the poisoned entry aged away. + */ +static void test_tm_nodeinfo_cache_pinsAgainstWarmTierKey(void) +{ + moduleConfig.traffic_management.nodeinfo_direct_response_max_hops = 10; + config.device.role = meshtastic_Config_DeviceConfig_Role_CLIENT; + mockNodeDB->clearCachedNode(); // hot store misses... + uint8_t warmKey[32]; + memset(warmKey, 0x55, 32); + mockNodeDB->warmStore.clear(); + mockNodeDB->warmStore.absorb(kTargetNode, 1000000, warmKey); // ...but the warm tier holds the key + + MockRouter mockRouter; + mockRouter.addInterface(std::unique_ptr(new MockRadioInterface())); + MeshService mockService; + router = &mockRouter; + service = &mockService; + + TrafficManagementModuleTestShim module; + + // Poisoning attempt with a key that mismatches the warm tier: must not be cached. + module.handleReceived(makeNodeInfoPacketWithKey(kTargetNode, "attacker", 0x66)); + uint8_t out[32] = {0}; + TEST_ASSERT_FALSE(module.copyPublicKey(kTargetNode, out, nullptr)); + + // The genuine key (matching the warm tier) is accepted. + module.handleReceived(makeNodeInfoPacketWithKey(kTargetNode, "genuine", 0x55)); + TEST_ASSERT_TRUE(module.copyPublicKey(kTargetNode, out, nullptr)); + TEST_ASSERT_EQUAL_UINT8(0x55, out[0]); + TEST_ASSERT_EQUAL_UINT8(0x55, out[31]); + + mockNodeDB->warmStore.clear(); +} + +/** + * Unsigned-identity gate, warm tier: a verified signer evicted to the warm tier must not be + * impersonatable. An attacker can forge an unsigned NodeInfo carrying the signer's real + * (public!) key - it passes the key pin and would inherit warm signer provenance - so the + * gate must classify warm-tier signers, not only hot-store ones. A signature-verified frame + * (control) is still learned. + */ +static void test_tm_nodeinfo_gate_blocksUnsignedWarmSignerForgery(void) +{ + moduleConfig.traffic_management.nodeinfo_direct_response_max_hops = 10; + config.device.role = meshtastic_Config_DeviceConfig_Role_CLIENT; + mockNodeDB->clearCachedNode(); + mockNodeDB->rollHotStore(); // hot store misses; only the warm tier knows the signer + uint8_t warmKey[32]; + memset(warmKey, 0x5A, 32); + mockNodeDB->warmStore.clear(); + mockNodeDB->warmStore.absorb(kTargetNode, 1000000, warmKey, 0, 0, /*signer=*/true); + + MockRouter mockRouter; + mockRouter.addInterface(std::unique_ptr(new MockRadioInterface())); + MeshService mockService; + router = &mockRouter; + service = &mockService; + + TrafficManagementModuleTestShim module; + + // Forgery: unsigned NodeInfo with the REAL key (passes the pin) and an attacker name. + meshtastic_MeshPacket forged = makeNodeInfoPacketWithKey(kTargetNode, "attacker", 0x5A); + forged.xeddsa_signed = false; + module.handleReceived(forged); + TEST_ASSERT_EQUAL_INT(-1, module.peekNodeInfoFlagsForTest(kTargetNode)); // nothing cached + meshtastic_User out = meshtastic_User_init_zero; + TEST_ASSERT_FALSE(module.copyUser(kTargetNode, out, nullptr)); + + // Control: the same identity, signature-verified, is learned. + meshtastic_MeshPacket genuine = makeNodeInfoPacketWithKey(kTargetNode, "genuine", 0x5A); + genuine.xeddsa_signed = true; + module.handleReceived(genuine); + TEST_ASSERT_TRUE(module.copyUser(kTargetNode, out, nullptr)); + TEST_ASSERT_EQUAL_STRING("genuine", out.long_name); + + mockNodeDB->warmStore.clear(); +} + +/** + * Reconcile seeding, serve-gate honesty: a hot-store identity is seeded into the cache by + * the maintenance sweep (name + key + signer provenance usable via copyUser/copyPublicKey), + * but is NEVER served as a spoofed reply until a genuine NODEINFO frame is heard - seeding + * and retention must not make a silent node look alive. + */ +static void test_tm_nodeinfo_reconcile_seedsFromHotStoreButNeverServes(void) +{ + moduleConfig.traffic_management.nodeinfo_direct_response_max_hops = 10; + config.device.role = meshtastic_Config_DeviceConfig_Role_CLIENT; + mockNodeDB->clearCachedNode(); + mockNodeDB->warmStore.clear(); + mockNodeDB->setHotNodeIdentity(kTargetNode, "hot-name", 0x77, /*signer=*/true); + + MockRouter mockRouter; + mockRouter.addInterface(std::unique_ptr(new MockRadioInterface())); + MeshService mockService; + router = &mockRouter; + service = &mockService; + + TrafficManagementModuleTestShim module; + module.runOnce(); // maintenance sweep -> reconcile seeds the hot identity + + // Seeded identity is available to the key pool and name rehydration... + uint8_t key[32] = {0}; + bool proven = false; + TEST_ASSERT_TRUE(module.copyPublicKey(kTargetNode, key, &proven)); + TEST_ASSERT_EQUAL_UINT8(0x77, key[0]); + TEST_ASSERT_TRUE(proven); // inherited from the hot store's signer bit, key-matched + meshtastic_User seeded = meshtastic_User_init_zero; + TEST_ASSERT_TRUE(module.copyUser(kTargetNode, seeded, nullptr)); + TEST_ASSERT_EQUAL_STRING("hot-name", seeded.long_name); + + // ...but a request for it is NOT answered: never observed, so never servable. + meshtastic_MeshPacket request = makeDecodedPacket(meshtastic_PortNum_NODEINFO_APP, kRemoteNode, kTargetNode); + request.decoded.want_response = true; + request.hop_start = 3; + request.hop_limit = 3; + TEST_ASSERT_EQUAL_INT(static_cast(ProcessMessage::CONTINUE), static_cast(module.handleReceived(request))); + TEST_ASSERT_EQUAL_UINT32(0, static_cast(mockRouter.sentPackets.size())); + + // A genuinely observed frame (key matches the NodeDB pin) makes it servable. The node is + // a known signer, so per #11035 only a signature-verified frame may drive cache writes - + // mark it as Router-verified, as the real receive path would. + meshtastic_MeshPacket observed = makeNodeInfoPacketWithKey(kTargetNode, "hot-name", 0x77); + observed.xeddsa_signed = true; + module.handleReceived(observed); + request.id = 0xCCCC0002; + TEST_ASSERT_EQUAL_INT(static_cast(ProcessMessage::STOP), static_cast(module.handleReceived(request))); + TEST_ASSERT_EQUAL_UINT32(1, static_cast(mockRouter.sentPackets.size())); + + mockNodeDB->rollHotStore(); +} + +/** + * Reconcile seeding from the warm tier yields a key-only record: usable by copyPublicKey + * (with the warm signer bit inherited), but never by copyUser - the warm tier keeps no + * names, and a nameless User must not reach name-rehydration. + */ +static void test_tm_nodeinfo_reconcile_seedsKeyOnlyFromWarmTier(void) +{ + moduleConfig.traffic_management.nodeinfo_direct_response_max_hops = 10; + config.device.role = meshtastic_Config_DeviceConfig_Role_CLIENT; + mockNodeDB->clearCachedNode(); + mockNodeDB->rollHotStore(); + uint8_t warmKey[32]; + memset(warmKey, 0x44, 32); + mockNodeDB->warmStore.clear(); + mockNodeDB->warmStore.absorb(kTargetNode, 1000000, warmKey, 0, 0, /*signer=*/true); + + MockRouter mockRouter; + mockRouter.addInterface(std::unique_ptr(new MockRadioInterface())); + MeshService mockService; + router = &mockRouter; + service = &mockService; + + TrafficManagementModuleTestShim module; + module.runOnce(); + + uint8_t key[32] = {0}; + bool proven = false; + TEST_ASSERT_TRUE(module.copyPublicKey(kTargetNode, key, &proven)); + TEST_ASSERT_EQUAL_UINT8(0x44, key[31]); + TEST_ASSERT_TRUE(proven); + meshtastic_User out = meshtastic_User_init_zero; + TEST_ASSERT_FALSE(module.copyUser(kTargetNode, out, nullptr)); // key-only record + + mockNodeDB->warmStore.clear(); +} + +/** + * Reconcile must not let a keyless hot-store identity erase a TOFU key this cache already + * learned (the same merge rule as onNodeIdentityCommitted): the hot name is adopted, the + * kept key survives for the copyPublicKey pool - and stays unproven, because the hot signer + * bit vouches only for a NodeDB-supplied key, never the kept TOFU one. + */ +static void test_tm_nodeinfo_reconcile_keepsTofuKeyOnKeylessHotIdentity(void) +{ + moduleConfig.traffic_management.nodeinfo_direct_response_max_hops = 10; + config.device.role = meshtastic_Config_DeviceConfig_Role_CLIENT; + mockNodeDB->clearCachedNode(); + mockNodeDB->rollHotStore(); + mockNodeDB->warmStore.clear(); + + MockRouter mockRouter; + mockRouter.addInterface(std::unique_ptr(new MockRadioInterface())); + MeshService mockService; + router = &mockRouter; + service = &mockService; + + TrafficManagementModuleTestShim module; + + // TOFU learn while NodeDB knows nothing about the node. + module.handleReceived(makeNodeInfoPacketWithKey(kTargetNode, "tofu-name", 0x33)); + uint8_t key[32] = {0}; + TEST_ASSERT_TRUE(module.copyPublicKey(kTargetNode, key, nullptr)); + + // NodeDB then learns the node with a User but NO key; its signed bit is even set, which + // must vouch for nothing here since NodeDB supplies no key to match it against. + mockNodeDB->setSignerHotNode(kTargetNode, "hot-name"); + module.runOnce(); // maintenance sweep -> reconcile adopts the hot identity + + bool proven = true; + memset(key, 0, sizeof(key)); + TEST_ASSERT_TRUE(module.copyPublicKey(kTargetNode, key, &proven)); // TOFU key survived + TEST_ASSERT_EQUAL_UINT8(0x33, key[0]); + TEST_ASSERT_EQUAL_UINT8(0x33, key[31]); + TEST_ASSERT_FALSE(proven); + meshtastic_User out = meshtastic_User_init_zero; + TEST_ASSERT_TRUE(module.copyUser(kTargetNode, out, nullptr)); + TEST_ASSERT_EQUAL_STRING("hot-name", out.long_name); // hot identity adopted + + mockNodeDB->rollHotStore(); +} + +/** + * Write-through hook: an identity committed through NodeDB::updateUser() lands in the + * NodeInfo cache immediately (name + key), without waiting for a maintenance sweep - and + * is still not servable, because the node was never actually heard. + */ +static void test_tm_nodeinfo_updateUserHook_writesThrough(void) +{ + moduleConfig.traffic_management.nodeinfo_direct_response_max_hops = 10; + config.device.role = meshtastic_Config_DeviceConfig_Role_CLIENT; + mockNodeDB->clearCachedNode(); + mockNodeDB->rollHotStore(); + mockNodeDB->warmStore.clear(); + + MockRouter mockRouter; + mockRouter.addInterface(std::unique_ptr(new MockRadioInterface())); + MeshService mockService; + router = &mockRouter; + service = &mockService; + + TrafficManagementModuleTestShim module; + trafficManagementModule = &module; // the NodeDB hook reaches the module via the global + + meshtastic_User user = meshtastic_User_init_zero; + strncpy(user.long_name, "committed", sizeof(user.long_name) - 1); + user.public_key.size = 32; + memset(user.public_key.bytes, 0x5A, 32); + mockNodeDB->updateUser(kTargetNode, user, 0); + + // Immediately visible to the key pool and name rehydration (no sweep has run)... + uint8_t key[32] = {0}; + bool proven = true; + TEST_ASSERT_TRUE(module.copyPublicKey(kTargetNode, key, &proven)); + TEST_ASSERT_EQUAL_UINT8(0x5A, key[0]); + TEST_ASSERT_FALSE(proven); // committed TOFU key: no signer bit on the node yet + meshtastic_User out = meshtastic_User_init_zero; + TEST_ASSERT_TRUE(module.copyUser(kTargetNode, out, nullptr)); + TEST_ASSERT_EQUAL_STRING("committed", out.long_name); + + // ...but known-not-heard is never servable. + meshtastic_MeshPacket request = makeDecodedPacket(meshtastic_PortNum_NODEINFO_APP, kRemoteNode, kTargetNode); + request.decoded.want_response = true; + request.hop_start = 3; + request.hop_limit = 3; + TEST_ASSERT_EQUAL_INT(static_cast(ProcessMessage::CONTINUE), static_cast(module.handleReceived(request))); + TEST_ASSERT_EQUAL_UINT32(0, static_cast(mockRouter.sentPackets.size())); + // trafficManagementModule and the hot store are reset in tearDown()/setUp(). +} + +/** + * Full removal: NodeDB::removeNodeByNum() must purge this module's caches too - both the + * NodeInfo identity entry (name/key) and the unified slot (next-hop hint etc.) - or the + * deleted identity would keep feeding the key pool and resurrect on next contact. + */ +static void test_tm_nodeinfo_removeNode_purgesCaches(void) +{ + moduleConfig.traffic_management.nodeinfo_direct_response_max_hops = 10; + config.device.role = meshtastic_Config_DeviceConfig_Role_CLIENT; + mockNodeDB->clearCachedNode(); + mockNodeDB->rollHotStore(); + mockNodeDB->warmStore.clear(); + + MockRouter mockRouter; + mockRouter.addInterface(std::unique_ptr(new MockRadioInterface())); + MeshService mockService; + router = &mockRouter; + service = &mockService; + + TrafficManagementModuleTestShim module; + trafficManagementModule = &module; // the NodeDB purge hook reaches the module via the global + + // Learn an identity (observed frame) and a routing hint for the node. + module.handleReceived(makeNodeInfoPacketWithKey(kTargetNode, "victim", 0x37)); + module.setNextHop(kTargetNode, 0x42); + uint8_t key[32] = {0}; + TEST_ASSERT_TRUE(module.copyPublicKey(kTargetNode, key, nullptr)); + TEST_ASSERT_EQUAL_UINT8(0x42, module.getNextHopHint(kTargetNode)); + + mockNodeDB->removeNodeByNum(kTargetNode); + + TEST_ASSERT_FALSE(module.copyPublicKey(kTargetNode, key, nullptr)); + meshtastic_User out = meshtastic_User_init_zero; + TEST_ASSERT_FALSE(module.copyUser(kTargetNode, out, nullptr)); + TEST_ASSERT_EQUAL_UINT8(0, module.getNextHopHint(kTargetNode)); + TEST_ASSERT_EQUAL_INT(-1, module.peekNodeInfoFlagsForTest(kTargetNode)); + // trafficManagementModule is reset in tearDown(). +} + +/** + * No timed eviction: a quiet keyed entry survives arbitrarily long (its key keeps feeding + * the pubkey pool via copyPublicKey), while tick saturation stops it being SERVED long + * before that. Slots are reclaimed only by LRU pressure on insert or an explicit purge. + */ +static void test_tm_nodeinfo_noTimedEviction_quietKeyedEntrySurvives(void) +{ + moduleConfig.traffic_management.nodeinfo_direct_response_max_hops = 10; + config.device.role = meshtastic_Config_DeviceConfig_Role_CLIENT; + mockNodeDB->clearCachedNode(); + mockNodeDB->rollHotStore(); + mockNodeDB->warmStore.clear(); + + MockRouter mockRouter; + mockRouter.addInterface(std::unique_ptr(new MockRadioInterface())); + MeshService mockService; + router = &mockRouter; + service = &mockService; + + TrafficManagementModuleTestShim module; + module.handleReceived(makeNodeInfoPacketWithKey(kTargetNode, "quiet", 0x21)); + module.markKeySignerProvenForTest(kTargetNode); // isolate: staleness, not the signed gate + + // Nine days of silence, swept every three days. The old design would have evicted the + // entry at the 7-day retention TTL; now nothing expires by timer. + for (int i = 0; i < 3; i++) { + TrafficManagementModule::s_testNowMs += 3UL * 24UL * 60UL * 60UL * 1000UL; + module.runOnce(); + } + + // The key still feeds the pool... + uint8_t key[32] = {0}; + TEST_ASSERT_TRUE(module.copyPublicKey(kTargetNode, key, nullptr)); + TEST_ASSERT_EQUAL_UINT8(0x21, key[0]); + + // ...but the serve gate saturated long ago: the request propagates unanswered. + meshtastic_MeshPacket request = makeDecodedPacket(meshtastic_PortNum_NODEINFO_APP, kRemoteNode, kTargetNode); + request.decoded.want_response = true; + request.hop_start = 3; + request.hop_limit = 3; + TEST_ASSERT_EQUAL_INT(static_cast(ProcessMessage::CONTINUE), static_cast(module.handleReceived(request))); + TEST_ASSERT_EQUAL_UINT32(0, static_cast(mockRouter.sentPackets.size())); +} +#endif // WARM_NODE_COUNT > 0 + +/** + * Feature #2: a key learned from an (unsigned) NodeInfo is served by copyPublicKey() as a + * trust-on-first-use key, so it can extend the encryption pool. signerProven must be false. + */ +static void test_tm_nodeinfo_copyPublicKey_servesTofuKey(void) +{ + moduleConfig.traffic_management.nodeinfo_direct_response_max_hops = 10; + config.device.role = meshtastic_Config_DeviceConfig_Role_CLIENT; + mockNodeDB->clearCachedNode(); + + MockRouter mockRouter; + mockRouter.addInterface(std::unique_ptr(new MockRadioInterface())); + MeshService mockService; + router = &mockRouter; + service = &mockService; + + TrafficManagementModuleTestShim module; + module.handleReceived(makeNodeInfoPacketWithKey(kTargetNode, "genuine", 0x33)); + + uint8_t key[32] = {0}; + bool proven = true; // must be overwritten to false + TEST_ASSERT_TRUE(module.copyPublicKey(kTargetNode, key, &proven)); + TEST_ASSERT_EQUAL_UINT8(0x33, key[0]); + TEST_ASSERT_EQUAL_UINT8(0x33, key[31]); + TEST_ASSERT_FALSE(proven); +} + +/** + * Feature #1: a later signature-verified NodeInfo upgrades the cached key's provenance to + * signer-proven (monotonic), while the key bytes stay pinned. + */ +static void test_tm_nodeinfo_copyPublicKey_upgradesToSignerProven(void) +{ + moduleConfig.traffic_management.nodeinfo_direct_response_max_hops = 10; + config.device.role = meshtastic_Config_DeviceConfig_Role_CLIENT; + mockNodeDB->clearCachedNode(); + + MockRouter mockRouter; + mockRouter.addInterface(std::unique_ptr(new MockRadioInterface())); + MeshService mockService; + router = &mockRouter; + service = &mockService; + + TrafficManagementModuleTestShim module; + + // First contact is unsigned -> TOFU. + module.handleReceived(makeNodeInfoPacketWithKey(kTargetNode, "genuine", 0x44)); + uint8_t key[32] = {0}; + bool proven = true; + TEST_ASSERT_TRUE(module.copyPublicKey(kTargetNode, key, &proven)); + TEST_ASSERT_FALSE(proven); + + // A later frame whose signature we verified upgrades provenance. + meshtastic_MeshPacket signed_ni = makeNodeInfoPacketWithKey(kTargetNode, "genuine", 0x44); + signed_ni.xeddsa_signed = true; + module.handleReceived(signed_ni); + + proven = false; + TEST_ASSERT_TRUE(module.copyPublicKey(kTargetNode, key, &proven)); + TEST_ASSERT_TRUE(proven); + TEST_ASSERT_EQUAL_UINT8(0x44, key[0]); // key unchanged +} + +/** + * copyPublicKey() reports a miss (false) for a node we have never cached. + */ +static void test_tm_nodeinfo_copyPublicKey_missReturnsFalse(void) +{ + mockNodeDB->clearCachedNode(); + TrafficManagementModuleTestShim module; + uint8_t key[32] = {0}; + TEST_ASSERT_FALSE(module.copyPublicKey(kTargetNode, key, nullptr)); +} + +/** + * copyUser() returns the full cached identity (name + key) for name rehydration, and reports a + * miss for an uncached node. + */ +static void test_tm_nodeinfo_copyUser_returnsCachedIdentity(void) +{ + moduleConfig.traffic_management.nodeinfo_direct_response_max_hops = 10; + config.device.role = meshtastic_Config_DeviceConfig_Role_CLIENT; + mockNodeDB->clearCachedNode(); + + MockRouter mockRouter; + mockRouter.addInterface(std::unique_ptr(new MockRadioInterface())); + MeshService mockService; + router = &mockRouter; + service = &mockService; + + TrafficManagementModuleTestShim module; + module.handleReceived(makeNodeInfoPacketWithKey(kTargetNode, "target-long", 0x77)); + + meshtastic_User out = meshtastic_User_init_zero; + TEST_ASSERT_TRUE(module.copyUser(kTargetNode, out, nullptr)); + TEST_ASSERT_EQUAL_STRING("target-long", out.long_name); + TEST_ASSERT_EQUAL_UINT32(32, out.public_key.size); + TEST_ASSERT_EQUAL_UINT8(0x77, out.public_key.bytes[0]); + + // Uncached node -> miss. + meshtastic_User miss = meshtastic_User_init_zero; + TEST_ASSERT_FALSE(module.copyUser(kRemoteNode, miss, nullptr)); +} + +#if TMM_NODEINFO_REPLAY_SIGNED_GATE +/** + * Replay gate (cache path): a fresh but trust-on-first-use (never signer-proven) cached entry + * is withheld - the reply is suppressed though the entry is fresh. + */ +static void test_tm_nodeinfo_directResponse_psramUnsignedNotServed(void) +{ + moduleConfig.traffic_management.nodeinfo_direct_response_max_hops = 10; + config.device.role = meshtastic_Config_DeviceConfig_Role_CLIENT; + mockNodeDB->clearCachedNode(); + + MockRouter mockRouter; + mockRouter.addInterface(std::unique_ptr(new MockRadioInterface())); + MeshService mockService; + router = &mockRouter; + service = &mockService; + + TrafficManagementModuleTestShim module; + // Cache a fresh but unsigned (TOFU) NodeInfo and do NOT mark it signer-proven. + module.handleReceived(makeNodeInfoPacket(kTargetNode, "target-long", "tg")); + + meshtastic_MeshPacket request = makeDecodedPacket(meshtastic_PortNum_NODEINFO_APP, kRemoteNode, kTargetNode); + request.decoded.want_response = true; + request.hop_start = 3; + request.hop_limit = 3; + + ProcessMessage result = module.handleReceived(request); + TEST_ASSERT_EQUAL_INT(static_cast(ProcessMessage::CONTINUE), static_cast(result)); + TEST_ASSERT_EQUAL_UINT32(0, static_cast(mockRouter.sentPackets.size())); +} +#endif // TMM_NODEINFO_REPLAY_SIGNED_GATE +#endif // !MESHTASTIC_EXCLUDE_PKI + +// Bit positions returned by peekNodeInfoFlagsForTest(). +constexpr int kFlagObserved = 1; +constexpr int kFlagMember = 2; +constexpr int kFlagFullUser = 4; + +/** + * Key-commit hook (ported from tmm-fix-superset): a TOFU learn lands the key in the pool + * without a User payload; manual verification upgrades provenance; a NodeDB-senior rotation + * replaces the key and never inherits the old key's verdict. + */ +static void test_tm_nodeinfo_keyHook_upsertsAndGovernsProvenance(void) +{ + mockNodeDB->clearCachedNode(); + TrafficManagementModuleTestShim module; + + uint8_t k[32]; + memset(k, 0x99, sizeof(k)); + module.onNodeKeyCommitted(kTargetNode, k, false); // TOFU-grade learn + uint8_t key[32] = {0}; + bool proven = true; + TEST_ASSERT_TRUE(module.copyPublicKey(kTargetNode, key, &proven)); + TEST_ASSERT_EQUAL_UINT8(0x99, key[0]); + TEST_ASSERT_FALSE(proven); + meshtastic_User out = meshtastic_User_init_zero; + TEST_ASSERT_FALSE(module.copyUser(kTargetNode, out, nullptr)); // key-only: nothing to rehydrate + + module.onNodeKeyCommitted(kTargetNode, k, true); // manual verification of the same key + TEST_ASSERT_TRUE(module.copyPublicKey(kTargetNode, key, &proven)); + TEST_ASSERT_TRUE(proven); + + uint8_t rotated[32]; + memset(rotated, 0xAA, sizeof(rotated)); + module.onNodeKeyCommitted(kTargetNode, rotated, false); // NodeDB-senior rotation + TEST_ASSERT_TRUE(module.copyPublicKey(kTargetNode, key, &proven)); + TEST_ASSERT_EQUAL_UINT8(0xAA, key[0]); + TEST_ASSERT_FALSE(proven); // rotated key must not inherit the old key's verdict +} + +/** + * Tick saturation (ported from tmm-fix-superset): the sweep clears hasObserved once the + * serve window passes, the entry itself persists (no TTL eviction), and a full 256-tick + * wrap of the clock cannot alias a saturated stamp back to "fresh". + */ +static void test_tm_nodeinfo_tickSaturation_sweepClearsObserved(void) +{ + moduleConfig.traffic_management.nodeinfo_direct_response_max_hops = 10; + config.device.role = meshtastic_Config_DeviceConfig_Role_CLIENT; + mockNodeDB->clearCachedNode(); + mockNodeDB->warmStore.clear(); + mockNodeDB->rollHotStore(); + + MockRouter mockRouter; + mockRouter.addInterface(std::unique_ptr(new MockRadioInterface())); + MeshService mockService; + router = &mockRouter; + service = &mockService; + + TrafficManagementModuleTestShim module; + module.handleReceived(makeNodeInfoPacket(kTargetNode, "target-long", "tg")); + module.markKeySignerProvenForTest(kTargetNode); + const uint32_t stampMs = TrafficManagementModule::s_testNowMs; + int flags = module.peekNodeInfoFlagsForTest(kTargetNode); + TEST_ASSERT_TRUE(flags >= 0 && (flags & kFlagObserved)); + + // Past the serve window (plus one tick for granularity): the sweep saturates the stamp + // but keeps the entry. + TrafficManagementModule::s_testNowMs += (6UL * 60UL * 60UL * 1000UL) + 180000UL + 1000UL; + module.runOnce(); + flags = module.peekNodeInfoFlagsForTest(kTargetNode); + TEST_ASSERT_TRUE(flags >= 0); + TEST_ASSERT_FALSE(flags & kFlagObserved); + + // Advance to exactly one full uint8 tick period after the original stamp: the raw tick + // age would read ~0 (fresh) if the bit were still trusted. It isn't - the cleared bit, + // not the tick value, is authoritative. + TrafficManagementModule::s_testNowMs = stampMs + 256UL * 180000UL; + meshtastic_MeshPacket request = makeDecodedPacket(meshtastic_PortNum_NODEINFO_APP, kRemoteNode, kTargetNode); + request.decoded.want_response = true; + request.hop_start = 3; + request.hop_limit = 3; + TEST_ASSERT_EQUAL_INT(static_cast(ProcessMessage::CONTINUE), static_cast(module.handleReceived(request))); + TEST_ASSERT_EQUAL_UINT32(0, static_cast(mockRouter.sentPackets.size())); +} + +/** + * Membership marking: the hourly reconcile marks entries whose node exists in NodeDB and + * clears the mark when the node is gone. A plain 60 s sweep does NOT refresh membership + * (that per-entry NodeDB scan was moved into the reconcile), so a passive NodeDB eviction + * lags by up to an hour; the entry itself persists (no TTL) and reconciliation-seeded + * identities stay unservable. + */ +static void test_tm_nodeinfo_reconcileMembershipMarking(void) +{ + moduleConfig.traffic_management.nodeinfo_direct_response_max_hops = 10; + config.device.role = meshtastic_Config_DeviceConfig_Role_CLIENT; + mockNodeDB->clearCachedNode(); + mockNodeDB->warmStore.clear(); + mockNodeDB->setHotNodeIdentity(kTargetNode, "seeded-name", 0x5C, /*signer=*/false); + + MockRouter mockRouter; + mockRouter.addInterface(std::unique_ptr(new MockRadioInterface())); + MeshService mockService; + router = &mockRouter; + service = &mockService; + + TrafficManagementModuleTestShim module; + module.runOnce(); // boot reconciliation pass seeds the hot identity + int flags = module.peekNodeInfoFlagsForTest(kTargetNode); + TEST_ASSERT_TRUE(flags >= 0); + TEST_ASSERT_TRUE(flags & kFlagMember); + TEST_ASSERT_TRUE(flags & kFlagFullUser); + TEST_ASSERT_FALSE(flags & kFlagObserved); + + // Node drops out of NodeDB entirely. Membership is refreshed by the hourly reconcile, + // not the per-minute sweep, so the very next sweep still shows the stale member bit - + // the documented up-to-an-hour lag for passive evictions. + mockNodeDB->rollHotStore(); + module.runOnce(); + flags = module.peekNodeInfoFlagsForTest(kTargetNode); + TEST_ASSERT_TRUE(flags >= 0); + TEST_ASSERT_TRUE(flags & kFlagMember); // stale by design between reconciles + + // After a reconcile interval's worth of sweeps, the mark is cleared. + for (int i = 0; i < 60; i++) + module.runOnce(); + flags = module.peekNodeInfoFlagsForTest(kTargetNode); + TEST_ASSERT_TRUE(flags >= 0); // entry persists (no TTL) ... + TEST_ASSERT_FALSE(flags & kFlagMember); // ... but is no longer pinned as a member +} + +/** + * cacheNodeInfoPacket() normalizes user.id to the packet sender: a payload claiming another + * node's id string must not plant a mismatched id into served replies or rehydrated names. + */ +static void test_tm_nodeinfo_cache_normalizesSpoofedUserId(void) +{ + mockNodeDB->clearCachedNode(); + TrafficManagementModuleTestShim module; + + meshtastic_MeshPacket packet = makeDecodedPacket(meshtastic_PortNum_NODEINFO_APP, kTargetNode, NODENUM_BROADCAST); + meshtastic_User user = meshtastic_User_init_zero; + snprintf(user.id, sizeof(user.id), "!%08x", static_cast(kRemoteNode)); // spoofed id + strncpy(user.long_name, "spoofer", sizeof(user.long_name) - 1); + packet.decoded.payload.size = + pb_encode_to_bytes(packet.decoded.payload.bytes, sizeof(packet.decoded.payload.bytes), &meshtastic_User_msg, &user); + module.handleReceived(packet); + + meshtastic_User out = meshtastic_User_init_zero; + TEST_ASSERT_TRUE(module.copyUser(kTargetNode, out, nullptr)); + char expected[16]; + snprintf(expected, sizeof(expected), "!%08x", static_cast(kTargetNode)); + TEST_ASSERT_EQUAL_STRING(expected, out.id); +} + +/** + * The write-through hooks share the module-enabled gate with maintenance: while traffic + * management is disabled in moduleConfig, neither hook fills the cache (content and + * maintenance stay keyed to the same condition); re-enabling restores write-through. + */ +static void test_tm_nodeinfo_hooks_noopWhileModuleDisabled(void) +{ + mockNodeDB->clearCachedNode(); + TrafficManagementModuleTestShim module; // constructed while enabled, so the cache is allocated + + moduleConfig.has_traffic_management = false; + uint8_t k[32]; + memset(k, 0x6B, sizeof(k)); + module.onNodeKeyCommitted(kTargetNode, k, true); + meshtastic_User user = meshtastic_User_init_zero; + strncpy(user.long_name, "disabled", sizeof(user.long_name) - 1); + module.onNodeIdentityCommitted(kTargetNode, user, false); + + uint8_t key[32] = {0}; + TEST_ASSERT_FALSE(module.copyPublicKey(kTargetNode, key, nullptr)); + meshtastic_User out = meshtastic_User_init_zero; + TEST_ASSERT_FALSE(module.copyUser(kTargetNode, out, nullptr)); + TEST_ASSERT_EQUAL_INT(-1, module.peekNodeInfoFlagsForTest(kTargetNode)); + + // Control: the same hook lands once the module is enabled again. + moduleConfig.has_traffic_management = true; + module.onNodeKeyCommitted(kTargetNode, k, true); + TEST_ASSERT_TRUE(module.copyPublicKey(kTargetNode, key, nullptr)); + TEST_ASSERT_EQUAL_UINT8(0x6B, key[0]); +} + +// Build a User for onNodeIdentityCommitted; keyByte < 0 means a keyless commit. +static meshtastic_User makeCommittedUser(const char *longName, int keyByte) +{ + meshtastic_User user = meshtastic_User_init_zero; + strncpy(user.long_name, longName, sizeof(user.long_name) - 1); + if (keyByte >= 0) { + user.public_key.size = 32; + memset(user.public_key.bytes, static_cast(keyByte), 32); + } + return user; +} + +/** + * Identity write-through hook, key semantics: a keyless commit keeps an already-learned key + * (NodeDB may commit an unpinned identity); provenance follows the committed key - kept + * while the key is unchanged, granted by signerKnown, and reset by a rotation. + */ +static void test_tm_nodeinfo_identityHook_keySemantics(void) +{ + mockNodeDB->clearCachedNode(); + TrafficManagementModuleTestShim module; + + // TOFU-grade identity commit with key K1. + module.onNodeIdentityCommitted(kTargetNode, makeCommittedUser("first", 0x51), false); + uint8_t key[32] = {0}; + bool proven = true; + TEST_ASSERT_TRUE(module.copyPublicKey(kTargetNode, key, &proven)); + TEST_ASSERT_EQUAL_UINT8(0x51, key[0]); + TEST_ASSERT_FALSE(proven); + + // Keyless commit: the name updates but the learned key must survive, still unproven. + module.onNodeIdentityCommitted(kTargetNode, makeCommittedUser("renamed", -1), false); + meshtastic_User out = meshtastic_User_init_zero; + TEST_ASSERT_TRUE(module.copyUser(kTargetNode, out, &proven)); + TEST_ASSERT_EQUAL_STRING("renamed", out.long_name); + TEST_ASSERT_EQUAL_UINT32(32, out.public_key.size); + TEST_ASSERT_EQUAL_UINT8(0x51, out.public_key.bytes[0]); + TEST_ASSERT_FALSE(proven); + + // signerKnown commit of the same key grants provenance... + module.onNodeIdentityCommitted(kTargetNode, makeCommittedUser("proven", 0x51), true); + TEST_ASSERT_TRUE(module.copyPublicKey(kTargetNode, key, &proven)); + TEST_ASSERT_TRUE(proven); + + // ...which a later same-key commit without the signer verdict does not revoke... + module.onNodeIdentityCommitted(kTargetNode, makeCommittedUser("still-proven", 0x51), false); + TEST_ASSERT_TRUE(module.copyPublicKey(kTargetNode, key, &proven)); + TEST_ASSERT_TRUE(proven); + + // ...but a rotated key never inherits the old key's verdict. + module.onNodeIdentityCommitted(kTargetNode, makeCommittedUser("rotated", 0x52), false); + TEST_ASSERT_TRUE(module.copyPublicKey(kTargetNode, key, &proven)); + TEST_ASSERT_EQUAL_UINT8(0x52, key[0]); + TEST_ASSERT_FALSE(proven); +} + +/** + * Read gate: copyPublicKey()/copyUser() share the module-enabled gate with the writers, so a + * cache populated while enabled stops feeding PKI resolution and name rehydration the moment + * the module is disabled - and resumes when re-enabled (the entry itself is never freed). + */ +static void test_tm_nodeinfo_reads_gatedWhileModuleDisabled(void) +{ + mockNodeDB->clearCachedNode(); + TrafficManagementModuleTestShim module; + + // Populate while enabled (setUp left has_traffic_management = true). + module.onNodeIdentityCommitted(kTargetNode, makeCommittedUser("present", 0x7C), false); + uint8_t key[32] = {0}; + TEST_ASSERT_TRUE(module.copyPublicKey(kTargetNode, key, nullptr)); + meshtastic_User out = meshtastic_User_init_zero; + TEST_ASSERT_TRUE(module.copyUser(kTargetNode, out, nullptr)); + + // Disabled: the frozen entry persists but the accessors refuse to serve it. + moduleConfig.has_traffic_management = false; + TEST_ASSERT_FALSE(module.copyPublicKey(kTargetNode, key, nullptr)); + TEST_ASSERT_FALSE(module.copyUser(kTargetNode, out, nullptr)); + + // Re-enabled: same entry served again (nothing was purged). + moduleConfig.has_traffic_management = true; + TEST_ASSERT_TRUE(module.copyPublicKey(kTargetNode, key, nullptr)); + TEST_ASSERT_EQUAL_UINT8(0x7C, key[0]); + TEST_ASSERT_TRUE(module.copyUser(kTargetNode, out, nullptr)); + TEST_ASSERT_EQUAL_STRING("present", out.long_name); +} + +#if !(MESHTASTIC_EXCLUDE_PKI) +// Fill `count` NodeInfo cache slots with keyless observed strangers (eviction tier 0), +// numbered from `baseNode`. +static void fillNodeInfoCacheWithKeylessStrangers(TrafficManagementModuleTestShim &module, uint32_t count, NodeNum baseNode) +{ + for (uint32_t i = 0; i < count; i++) + module.handleReceived(makeNodeInfoPacket(baseNode + i, "filler", "fl")); +} + +// Fill `count` NodeInfo cache slots with TOFU-keyed observed strangers (eviction tier 1), +// numbered from `baseNode`, all sharing key byte 0x0F. +static void fillNodeInfoCacheWithTofuStrangers(TrafficManagementModuleTestShim &module, uint32_t count, NodeNum baseNode) +{ + for (uint32_t i = 0; i < count; i++) + module.handleReceived(makeNodeInfoPacketWithKey(baseNode + i, "filler", 0x0F)); +} + +/** + * Tiered LRU eviction, tier boundaries: with the cache exactly full, a new stranger's insert + * evicts a keyless stranger - never a TOFU-keyed entry, a signer-proven entry, or a NodeDB + * member - even though those higher-tier entries are the OLDEST observations in the cache + * (tier outranks recency). + */ +static void test_tm_nodeinfo_eviction_keyedTiersOutrankKeyless(void) +{ + mockNodeDB->clearCachedNode(); + mockNodeDB->rollHotStore(); + TrafficManagementModuleTestShim module; + + constexpr NodeNum kTofu = 0x51000001, kProven = 0x51000002, kMember = 0x51000003, kNewcomer = 0x51000004; + module.handleReceived(makeNodeInfoPacketWithKey(kTofu, "tofu", 0x11)); + module.handleReceived(makeNodeInfoPacketWithKey(kProven, "proven", 0x22)); + module.markKeySignerProvenForTest(kProven); + uint8_t memberKey[32]; + memset(memberKey, 0x33, sizeof(memberKey)); + module.onNodeKeyCommitted(kMember, memberKey, false); + + // Age the specials by two observation ticks, then fill every remaining slot with + // fresher keyless strangers so the cache is exactly full. + TrafficManagementModule::s_testNowMs += 2UL * 180000UL; + const uint16_t cap = TrafficManagementModuleTestShim::nodeInfoCacheCapacityForTest(); + fillNodeInfoCacheWithKeylessStrangers(module, cap - 3u, 0x40000000); + + // The newcomer's insert must claim a keyless slot. + module.handleReceived(makeNodeInfoPacket(kNewcomer, "newcomer", "nc")); + + uint8_t key[32] = {0}; + TEST_ASSERT_TRUE(module.copyPublicKey(kTofu, key, nullptr)); + TEST_ASSERT_TRUE(module.copyPublicKey(kProven, key, nullptr)); + TEST_ASSERT_TRUE(module.copyPublicKey(kMember, key, nullptr)); + TEST_ASSERT_TRUE(module.peekNodeInfoFlagsForTest(kNewcomer) >= 0); +} + +/** + * Tiered LRU eviction, keyed tiers: in a cache saturated with TOFU-keyed strangers, keyed + * inserts displace TOFU entries while a signer-proven stranger and a NodeDB member survive + * (keyless < TOFU < signer-proven, +membership). + */ +static void test_tm_nodeinfo_eviction_tofuLosesBeforeProvenAndMember(void) +{ + mockNodeDB->clearCachedNode(); + mockNodeDB->rollHotStore(); + TrafficManagementModuleTestShim module; + + constexpr NodeNum kFillBase = 0x40000000; + constexpr NodeNum kProven = 0x52000001, kMember = 0x52000002, kNew1 = 0x52000003, kNew2 = 0x52000004; + + const uint16_t cap = TrafficManagementModuleTestShim::nodeInfoCacheCapacityForTest(); + fillNodeInfoCacheWithTofuStrangers(module, cap - 2u, kFillBase); + module.handleReceived(makeNodeInfoPacketWithKey(kProven, "proven", 0x22)); + module.markKeySignerProvenForTest(kProven); + uint8_t memberKey[32]; + memset(memberKey, 0x33, sizeof(memberKey)); + module.onNodeKeyCommitted(kMember, memberKey, false); + + // Age the saturated cache so each newcomer is fresher than the remaining fillers + // (otherwise the second insert would reclaim the first newcomer's equal-age slot). + TrafficManagementModule::s_testNowMs += 2UL * 180000UL; + module.handleReceived(makeNodeInfoPacketWithKey(kNew1, "new1", 0x44)); + module.handleReceived(makeNodeInfoPacketWithKey(kNew2, "new2", 0x55)); + + uint8_t key[32] = {0}; + TEST_ASSERT_TRUE(module.copyPublicKey(kProven, key, nullptr)); + TEST_ASSERT_TRUE(module.copyPublicKey(kMember, key, nullptr)); + TEST_ASSERT_TRUE(module.copyPublicKey(kNew1, key, nullptr)); + TEST_ASSERT_TRUE(module.copyPublicKey(kNew2, key, nullptr)); + // The two victims were the first TOFU fillers scanned, not the protected entries. + TEST_ASSERT_FALSE(module.copyPublicKey(kFillBase + 0, key, nullptr)); + TEST_ASSERT_FALSE(module.copyPublicKey(kFillBase + 1, key, nullptr)); +} + +/** + * Within-tier LRU: among same-tier entries the stalest observation is evicted first, not + * whichever slot happens to be scanned first. + */ +static void test_tm_nodeinfo_eviction_withinTierStalestLoses(void) +{ + mockNodeDB->clearCachedNode(); + mockNodeDB->rollHotStore(); + TrafficManagementModuleTestShim module; + + constexpr NodeNum kFillBase = 0x40000000; + constexpr NodeNum kStale = 0x53000001, kNewcomer = 0x53000002; + module.handleReceived(makeNodeInfoPacketWithKey(kStale, "stale", 0x11)); + + // Everything else is observed two ticks later... + TrafficManagementModule::s_testNowMs += 2UL * 180000UL; + const uint16_t cap = TrafficManagementModuleTestShim::nodeInfoCacheCapacityForTest(); + fillNodeInfoCacheWithTofuStrangers(module, cap - 1u, kFillBase); + + // ...so the newcomer's insert reclaims kStale's slot specifically. + module.handleReceived(makeNodeInfoPacketWithKey(kNewcomer, "newcomer", 0x22)); + + uint8_t key[32] = {0}; + TEST_ASSERT_FALSE(module.copyPublicKey(kStale, key, nullptr)); + TEST_ASSERT_TRUE(module.peekNodeInfoFlagsForTest(kNewcomer) >= 0); + TEST_ASSERT_TRUE(module.copyPublicKey(kFillBase + 0, key, nullptr)); // fresher same-tier entry survives +} + +/** + * Member saturation: with every slot holding a NodeDB member, the write-through hooks skip + * rather than churn one member out for another (spareMembers), while the packet path - a + * genuinely observed frame - does evict a member. + */ +static void test_tm_nodeinfo_memberSaturated_hooksSkipPacketPathEvicts(void) +{ + mockNodeDB->clearCachedNode(); + mockNodeDB->rollHotStore(); + TrafficManagementModuleTestShim module; + + constexpr NodeNum kFillBase = 0x40000000; + constexpr NodeNum kExtra = 0x54000001, kObserved = 0x54000002; + + const uint16_t cap = TrafficManagementModuleTestShim::nodeInfoCacheCapacityForTest(); + uint8_t k[32]; + memset(k, 0x11, sizeof(k)); + for (uint32_t i = 0; i < cap; i++) + module.onNodeKeyCommitted(kFillBase + i, k, false); + + // Hook inserts for one more member: skipped rather than evicting an existing member. + uint8_t extraKey[32]; + memset(extraKey, 0x22, sizeof(extraKey)); + module.onNodeKeyCommitted(kExtra, extraKey, false); + uint8_t key[32] = {0}; + TEST_ASSERT_FALSE(module.copyPublicKey(kExtra, key, nullptr)); + module.onNodeIdentityCommitted(kExtra, makeCommittedUser("extra", 0x22), false); + meshtastic_User out = meshtastic_User_init_zero; + TEST_ASSERT_FALSE(module.copyUser(kExtra, out, nullptr)); + + // The packet path may churn a member: the observed stranger lands (in the first-scanned + // member's slot) and is correctly NOT marked a member itself. + module.handleReceived(makeNodeInfoPacketWithKey(kObserved, "observed", 0x33)); + TEST_ASSERT_TRUE(module.copyPublicKey(kObserved, key, nullptr)); + const int flags = module.peekNodeInfoFlagsForTest(kObserved); + TEST_ASSERT_TRUE(flags >= 0); + TEST_ASSERT_TRUE(flags & kFlagObserved); + TEST_ASSERT_FALSE(flags & kFlagMember); + TEST_ASSERT_FALSE(module.copyPublicKey(kFillBase + 0, key, nullptr)); // the evicted member +} + +/** + * Full DB reset: NodeDB::resetNodes() purges this module's caches through purgeAll(), so no + * identity, key, or next-hop hint survives a user-initiated "forget everything". + */ +static void test_tm_nodeinfo_resetNodes_purgesAllCaches(void) +{ + moduleConfig.traffic_management.nodeinfo_direct_response_max_hops = 10; + config.device.role = meshtastic_Config_DeviceConfig_Role_CLIENT; + mockNodeDB->clearCachedNode(); + mockNodeDB->rollHotStore(); + + MockRouter mockRouter; + mockRouter.addInterface(std::unique_ptr(new MockRadioInterface())); + MeshService mockService; + router = &mockRouter; + service = &mockService; + + TrafficManagementModuleTestShim module; + trafficManagementModule = &module; // the NodeDB purge hook reaches the module via the global + + module.handleReceived(makeNodeInfoPacketWithKey(kTargetNode, "victim", 0x37)); + module.setNextHop(kTargetNode, 0x42); + uint8_t key[32] = {0}; + TEST_ASSERT_TRUE(module.copyPublicKey(kTargetNode, key, nullptr)); + TEST_ASSERT_EQUAL_UINT8(0x42, module.getNextHopHint(kTargetNode)); + + mockNodeDB->resetNodes(); + + TEST_ASSERT_FALSE(module.copyPublicKey(kTargetNode, key, nullptr)); + meshtastic_User out = meshtastic_User_init_zero; + TEST_ASSERT_FALSE(module.copyUser(kTargetNode, out, nullptr)); + TEST_ASSERT_EQUAL_INT(-1, module.peekNodeInfoFlagsForTest(kTargetNode)); + TEST_ASSERT_EQUAL_UINT8(0, module.getNextHopHint(kTargetNode)); + // trafficManagementModule is reset in tearDown(). +} + +/** + * A NodeInfo advertising OUR OWN public key is impersonating us and must never be cached + * (mirrors NodeDB::updateUser()'s key hygiene for the store that feeds spoofed replies). + */ +static void test_tm_nodeinfo_cache_dropsFrameCarryingOwnerKey(void) +{ + mockNodeDB->clearCachedNode(); + TrafficManagementModuleTestShim module; + + owner.public_key.size = 32; + memset(owner.public_key.bytes, 0x42, 32); + + module.handleReceived(makeNodeInfoPacketWithKey(kTargetNode, "imposter", 0x42)); + + TEST_ASSERT_EQUAL_INT(-1, module.peekNodeInfoFlagsForTest(kTargetNode)); + uint8_t key[32] = {0}; + TEST_ASSERT_FALSE(module.copyPublicKey(kTargetNode, key, nullptr)); + // owner.public_key is reset in tearDown() (guaranteed even if an assertion above aborts). +} +#endif // !MESHTASTIC_EXCLUDE_PKI #endif +/** + * Verify the NodeDB-fallback direct-response path (no NodeInfo cache) refuses to spoof a + * reply for a node NodeDB last heard beyond the serve window, but still answers a fresh one. + */ +static void test_tm_nodeinfo_directResponse_fallbackStaleEntryNotServed(void) +{ + moduleConfig.traffic_management.nodeinfo_direct_response_max_hops = 10; + config.device.role = meshtastic_Config_DeviceConfig_Role_CLIENT; + + // Drive getTime() to a known uptime so sinceLastSeen() is deterministic. + setBootRelativeTimeForUnitTest(1000000); + const uint32_t now = getTime(); + + mockNodeDB->setCachedNode(kTargetNode); + mockNodeDB->cachedNodeForTest().last_heard = now - (7UL * 60UL * 60UL); // 7 h ago -> stale + // Signer-proven so staleness is the sole reason this is not served (isolates the gate under test). + mockNodeDB->cachedNodeForTest().bitfield |= NODEINFO_BITFIELD_HAS_XEDDSA_SIGNED_MASK; + + MockRouter mockRouter; + mockRouter.addInterface(std::unique_ptr(new MockRadioInterface())); + MeshService mockService; + router = &mockRouter; + service = &mockService; + + TrafficManagementModuleTestShim module; + module.dropNodeInfoCacheForTest(); // exercise the NodeDB fallback path + meshtastic_MeshPacket request = makeDecodedPacket(meshtastic_PortNum_NODEINFO_APP, kRemoteNode, kTargetNode); + request.decoded.want_response = true; + request.hop_start = 3; + request.hop_limit = 3; + + ProcessMessage result = module.handleReceived(request); + meshtastic_TrafficManagementStats stats = module.getStats(); + + TEST_ASSERT_EQUAL_INT(static_cast(ProcessMessage::CONTINUE), static_cast(result)); + TEST_ASSERT_EQUAL_UINT32(0, stats.nodeinfo_cache_hits); + TEST_ASSERT_EQUAL_UINT32(0, static_cast(mockRouter.sentPackets.size())); + + resetRTCStateForTests(); +} + +/** + * Verify the NodeDB-fallback path still answers when the node was heard recently. + */ +static void test_tm_nodeinfo_directResponse_fallbackFreshEntryServed(void) +{ + moduleConfig.traffic_management.nodeinfo_direct_response_max_hops = 10; + config.device.role = meshtastic_Config_DeviceConfig_Role_CLIENT; + + setBootRelativeTimeForUnitTest(1000000); + const uint32_t now = getTime(); + + mockNodeDB->setCachedNode(kTargetNode); + mockNodeDB->cachedNodeForTest().last_heard = now - 60UL; // 1 min ago -> fresh + // Signed-only replay gate (default) requires the target be a known signer to be served. + mockNodeDB->cachedNodeForTest().bitfield |= NODEINFO_BITFIELD_HAS_XEDDSA_SIGNED_MASK; + + MockRouter mockRouter; + mockRouter.addInterface(std::unique_ptr(new MockRadioInterface())); + MeshService mockService; + router = &mockRouter; + service = &mockService; + + TrafficManagementModuleTestShim module; + module.dropNodeInfoCacheForTest(); // exercise the NodeDB fallback path + meshtastic_MeshPacket request = makeDecodedPacket(meshtastic_PortNum_NODEINFO_APP, kRemoteNode, kTargetNode); + request.decoded.want_response = true; + request.hop_start = 3; + request.hop_limit = 3; + + ProcessMessage result = module.handleReceived(request); + meshtastic_TrafficManagementStats stats = module.getStats(); + + TEST_ASSERT_EQUAL_INT(static_cast(ProcessMessage::STOP), static_cast(result)); + TEST_ASSERT_EQUAL_UINT32(1, stats.nodeinfo_cache_hits); + TEST_ASSERT_EQUAL_UINT32(1, static_cast(mockRouter.sentPackets.size())); + + resetRTCStateForTests(); +} + +/** + * Per-target direct-response throttle on the NodeDB-fallback path (no PSRAM NodeInfo cache). The + * per-target RAM table is not the cache, so it throttles this path identically: a burst for a fresh + * node yields one spoofed reply per 60 s window, even from a different requester, then serves again. + */ +static void test_tm_nodeinfo_directResponse_fallbackThrottlesWithinWindow(void) +{ + moduleConfig.traffic_management.nodeinfo_direct_response_max_hops = 10; + config.device.role = meshtastic_Config_DeviceConfig_Role_CLIENT; + + setBootRelativeTimeForUnitTest(1000000); + const uint32_t now = getTime(); + TrafficManagementModule::s_testNowMs = 3600000; // known base for the throttle windows + + mockNodeDB->setCachedNode(kTargetNode); + mockNodeDB->cachedNodeForTest().last_heard = now - 60UL; // fresh, passes staleness gate + // Signed-only replay gate (default) requires the target be a known signer to be served. + mockNodeDB->cachedNodeForTest().bitfield |= NODEINFO_BITFIELD_HAS_XEDDSA_SIGNED_MASK; + + MockRouter mockRouter; + mockRouter.addInterface(std::unique_ptr(new MockRadioInterface())); + MeshService mockService; + router = &mockRouter; + service = &mockService; + + TrafficManagementModuleTestShim module; + module.dropNodeInfoCacheForTest(); // exercise the NodeDB fallback path + meshtastic_MeshPacket request = makeDecodedPacket(meshtastic_PortNum_NODEINFO_APP, kRemoteNode, kTargetNode); + request.decoded.want_response = true; + request.hop_start = 3; + request.hop_limit = 3; + + // First request: served from the NodeDB fallback. + request.id = 0xBBBB0001; + TEST_ASSERT_EQUAL_INT(static_cast(ProcessMessage::STOP), static_cast(module.handleReceived(request))); + TEST_ASSERT_EQUAL_UINT32(1, static_cast(mockRouter.sentPackets.size())); + + // Second request within the window, from a DIFFERENT requester so only the per-target axis can + // throttle it: our spoofed TX is suppressed, but the request is NOT consumed - it CONTINUEs so + // the genuine target (or another cache-holder) can answer. + TrafficManagementModule::s_testNowMs += 5000; // 5 s < 60 s window + request.from = kRemoteNode2; + request.id = 0xBBBB0002; + TEST_ASSERT_EQUAL_INT(static_cast(ProcessMessage::CONTINUE), static_cast(module.handleReceived(request))); + TEST_ASSERT_FALSE(module.ignoreRequestFlag()); + TEST_ASSERT_EQUAL_UINT32(1, static_cast(mockRouter.sentPackets.size())); + + // Past the per-target window: served again. Back to the original requester (its 60 s per-requester + // window from the first reply has also elapsed), so only the per-target release is under test. + TrafficManagementModule::s_testNowMs += 60000; // now > 60 s since first reply + request.from = kRemoteNode; + request.id = 0xBBBB0003; + TEST_ASSERT_EQUAL_INT(static_cast(ProcessMessage::STOP), static_cast(module.handleReceived(request))); + TEST_ASSERT_EQUAL_UINT32(2, static_cast(mockRouter.sentPackets.size())); + + resetRTCStateForTests(); +} + +/** + * The per-requester and global-floor axes, isolated from per-target (which has its own cache/fallback + * tests). Both bound spoofed direct replies by the unauthenticated requester address and by total + * airtime; each step keeps the per-target axis fresh (distinct targets) so only the axis under test + * gates the reply: + * - the same requester asking for a DIFFERENT target within 60 s is still throttled (the + * reflector-flood case: the per-target axis alone would not bound this); + * - a fresh requester is served, but only once the 1 s global floor has passed; + * - after the per-requester window elapses, the original requester is served again. + */ +static void test_tm_nodeinfo_directResponse_perRequesterAndGlobalFloor(void) +{ + moduleConfig.traffic_management.nodeinfo_direct_response_max_hops = 10; + config.device.role = meshtastic_Config_DeviceConfig_Role_CLIENT; + mockNodeDB->clearCachedNode(); + + const uint32_t base = 3600000; + TrafficManagementModule::s_testNowMs = base; + + MockRouter mockRouter; + mockRouter.addInterface(std::unique_ptr(new MockRadioInterface())); + MeshService mockService; + router = &mockRouter; + service = &mockService; + + TrafficManagementModuleTestShim module; + + // Three distinct, freshly-observed, signer-proven targets. Using a fresh target on each step keeps + // the per-target axis from ever being the bound here, so the reply is gated only by the axis under + // test: per-requester (step 2) or the global floor (step 4). + constexpr NodeNum kTargetA = 0x33330001, kTargetB = 0x33330002, kTargetC = 0x33330003; + constexpr NodeNum kRemoteNode3 = 0x66666666; + for (NodeNum t : {kTargetA, kTargetB, kTargetC}) { + module.handleReceived(makeNodeInfoPacket(t, "target-long", "tg")); + module.markKeySignerProvenForTest(t); + } + + meshtastic_MeshPacket request = makeDecodedPacket(meshtastic_PortNum_NODEINFO_APP, kRemoteNode, kTargetA); + request.decoded.want_response = true; + request.hop_start = 3; + request.hop_limit = 3; + + // First reply for this requester: served. + request.id = 0xC0DE0001; + TEST_ASSERT_EQUAL_INT(static_cast(ProcessMessage::STOP), static_cast(module.handleReceived(request))); + TEST_ASSERT_EQUAL_UINT32(1, static_cast(mockRouter.sentPackets.size())); + + // Same requester, DIFFERENT target, 10 s later: the per-target throttle can't fire (fresh entry), + // but the per-requester window (60 s) still suppresses our TX. Forwarded, not sent. + TrafficManagementModule::s_testNowMs = base + 10000; + request.to = kTargetB; + request.id = 0xC0DE0002; + TEST_ASSERT_EQUAL_INT(static_cast(ProcessMessage::CONTINUE), static_cast(module.handleReceived(request))); + TEST_ASSERT_EQUAL_UINT32(1, static_cast(mockRouter.sentPackets.size())); + + // A DIFFERENT requester (fresh slot) is served - the 1 s global floor has long passed. + request.from = kRemoteNode2; + request.id = 0xC0DE0003; + TEST_ASSERT_EQUAL_INT(static_cast(ProcessMessage::STOP), static_cast(module.handleReceived(request))); + TEST_ASSERT_EQUAL_UINT32(2, static_cast(mockRouter.sentPackets.size())); + + // Yet a third fresh requester in the SAME instant, for a fresh target (so per-target can't be the + // cause), is deferred by the 1 s global airtime floor. Forwarded, not sent. + request.from = kRemoteNode3; + request.to = kTargetC; + request.id = 0xC0DE0004; + TEST_ASSERT_EQUAL_INT(static_cast(ProcessMessage::CONTINUE), static_cast(module.handleReceived(request))); + TEST_ASSERT_EQUAL_UINT32(2, static_cast(mockRouter.sentPackets.size())); + + // After the per-requester window elapses, the original requester is served again. + TrafficManagementModule::s_testNowMs = base + 70000; + request.from = kRemoteNode; + request.to = kTargetA; + request.id = 0xC0DE0005; + TEST_ASSERT_EQUAL_INT(static_cast(ProcessMessage::STOP), static_cast(module.handleReceived(request))); + TEST_ASSERT_EQUAL_UINT32(3, static_cast(mockRouter.sentPackets.size())); +} + +#if TMM_NODEINFO_REPLAY_SIGNED_GATE +/** + * Replay gate (fallback path): a fresh NodeDB node that is NOT a known signer is withheld - + * the courtesy reply is suppressed and the genuine request propagates (CONTINUE, no TX). + */ +static void test_tm_nodeinfo_directResponse_fallbackUnsignedNotServed(void) +{ + moduleConfig.traffic_management.nodeinfo_direct_response_max_hops = 10; + config.device.role = meshtastic_Config_DeviceConfig_Role_CLIENT; + setBootRelativeTimeForUnitTest(1000000); + const uint32_t now = getTime(); + + mockNodeDB->setCachedNode(kTargetNode); + mockNodeDB->cachedNodeForTest().last_heard = now - 60UL; // fresh, passes staleness + // Deliberately NOT flagged as a signer -> the signed-only gate must withhold the reply. + + MockRouter mockRouter; + mockRouter.addInterface(std::unique_ptr(new MockRadioInterface())); + MeshService mockService; + router = &mockRouter; + service = &mockService; + + TrafficManagementModuleTestShim module; + module.dropNodeInfoCacheForTest(); // exercise the NodeDB fallback path + meshtastic_MeshPacket request = makeDecodedPacket(meshtastic_PortNum_NODEINFO_APP, kRemoteNode, kTargetNode); + request.decoded.want_response = true; + request.hop_start = 3; + request.hop_limit = 3; + + ProcessMessage result = module.handleReceived(request); + TEST_ASSERT_EQUAL_INT(static_cast(ProcessMessage::CONTINUE), static_cast(result)); + TEST_ASSERT_EQUAL_UINT32(0, static_cast(mockRouter.sentPackets.size())); + + resetRTCStateForTests(); +} +#endif // TMM_NODEINFO_REPLAY_SIGNED_GATE + /** * Verify relayed telemetry broadcasts are NOT hop-exhausted. * exhaust_hop_telemetry / exhaust_hop_position have been removed from the config @@ -1747,7 +3176,20 @@ void setUp(void) { resetTrafficConfig(); } -void tearDown(void) {} +void tearDown(void) +{ + // Runs even when a TEST_ASSERT_* aborts a test mid-way (Unity longjmps out, skipping any + // cleanup the test itself does after the assertion), so per-test global state can never + // dangle into later cases. The dangling-pointer path is concrete: a test points the global + // at its stack `module`, and the next setUp()'s resetNodes() would then call + // trafficManagementModule->purgeAll() on a destroyed object. + trafficManagementModule = nullptr; + owner.public_key.size = 0; + memset(owner.public_key.bytes, 0, sizeof(owner.public_key.bytes)); + // Neutralize the RTC fake clock a fallback test may have set (the virtual s_testNowMs is + // already reset by setUp via resetTrafficConfig). + setBootRelativeTimeForUnitTest(0); +} TM_TEST_ENTRY void setup() { @@ -1771,12 +3213,54 @@ TM_TEST_ENTRY void setup() RUN_TEST(test_tm_nodeinfo_directResponse_learnsRequestorNodeInfo); RUN_TEST(test_tm_nodeinfo_directResponse_ignoresUnsignedSignerIdentity); RUN_TEST(test_tm_nodeinfo_clientClamp_skipsWhenNotDirect); -#if !(defined(ARCH_ESP32) && defined(BOARD_HAS_PSRAM)) RUN_TEST(test_tm_nodeinfo_directResponse_withoutNodeDbEntry_skips); + RUN_TEST(test_tm_nodeinfo_directResponse_fallbackStaleEntryNotServed); + RUN_TEST(test_tm_nodeinfo_directResponse_fallbackFreshEntryServed); + RUN_TEST(test_tm_nodeinfo_directResponse_fallbackThrottlesWithinWindow); + RUN_TEST(test_tm_nodeinfo_directResponse_perRequesterAndGlobalFloor); +#if TMM_NODEINFO_REPLAY_SIGNED_GATE + RUN_TEST(test_tm_nodeinfo_directResponse_fallbackUnsignedNotServed); #endif -#if defined(ARCH_ESP32) && defined(BOARD_HAS_PSRAM) +#if TMM_HAS_NODEINFO_CACHE RUN_TEST(test_tm_nodeinfo_directResponse_psramCacheRespondsAndPreservesBitfield); RUN_TEST(test_tm_nodeinfo_directResponse_psramMissDoesNotFallbackToNodeDb); + RUN_TEST(test_tm_nodeinfo_directResponse_psramStaleEntryNotServed); + RUN_TEST(test_tm_nodeinfo_directResponse_psramThrottlesWithinWindow); +#if !(MESHTASTIC_EXCLUDE_PKI) + RUN_TEST(test_tm_nodeinfo_cache_rejectsMismatchedKey); +#if WARM_NODE_COUNT > 0 + RUN_TEST(test_tm_nodeinfo_cache_pinsAgainstWarmTierKey); + RUN_TEST(test_tm_nodeinfo_gate_blocksUnsignedWarmSignerForgery); + RUN_TEST(test_tm_nodeinfo_reconcile_seedsFromHotStoreButNeverServes); + RUN_TEST(test_tm_nodeinfo_reconcile_seedsKeyOnlyFromWarmTier); + RUN_TEST(test_tm_nodeinfo_reconcile_keepsTofuKeyOnKeylessHotIdentity); + RUN_TEST(test_tm_nodeinfo_updateUserHook_writesThrough); + RUN_TEST(test_tm_nodeinfo_removeNode_purgesCaches); + RUN_TEST(test_tm_nodeinfo_noTimedEviction_quietKeyedEntrySurvives); +#endif + RUN_TEST(test_tm_nodeinfo_copyPublicKey_servesTofuKey); + RUN_TEST(test_tm_nodeinfo_copyPublicKey_upgradesToSignerProven); + RUN_TEST(test_tm_nodeinfo_copyPublicKey_missReturnsFalse); + RUN_TEST(test_tm_nodeinfo_copyUser_returnsCachedIdentity); +#if TMM_NODEINFO_REPLAY_SIGNED_GATE + RUN_TEST(test_tm_nodeinfo_directResponse_psramUnsignedNotServed); +#endif +#endif + RUN_TEST(test_tm_nodeinfo_keyHook_upsertsAndGovernsProvenance); + RUN_TEST(test_tm_nodeinfo_tickSaturation_sweepClearsObserved); + RUN_TEST(test_tm_nodeinfo_reconcileMembershipMarking); + RUN_TEST(test_tm_nodeinfo_cache_normalizesSpoofedUserId); + RUN_TEST(test_tm_nodeinfo_hooks_noopWhileModuleDisabled); + RUN_TEST(test_tm_nodeinfo_identityHook_keySemantics); + RUN_TEST(test_tm_nodeinfo_reads_gatedWhileModuleDisabled); +#if !(MESHTASTIC_EXCLUDE_PKI) + RUN_TEST(test_tm_nodeinfo_eviction_keyedTiersOutrankKeyless); + RUN_TEST(test_tm_nodeinfo_eviction_tofuLosesBeforeProvenAndMember); + RUN_TEST(test_tm_nodeinfo_eviction_withinTierStalestLoses); + RUN_TEST(test_tm_nodeinfo_memberSaturated_hooksSkipPacketPathEvicts); + RUN_TEST(test_tm_nodeinfo_resetNodes_purgesAllCaches); + RUN_TEST(test_tm_nodeinfo_cache_dropsFrameCarryingOwnerKey); +#endif #endif RUN_TEST(test_tm_alterReceived_telemetryBroadcast_hopLimitUnchanged); RUN_TEST(test_tm_alterReceived_skipsLocalAndUnicast); diff --git a/test/test_xmodem/test_main.cpp b/test/test_xmodem/test_main.cpp index 816c78e9c..c6a20fdf0 100644 --- a/test/test_xmodem/test_main.cpp +++ b/test/test_xmodem/test_main.cpp @@ -21,6 +21,22 @@ void test_xmodem_rejects_dotdot_traversal(void) TEST_ASSERT_FALSE(XModemAdapter::isValidFilename("/..")); } +void test_xmodem_rejects_backslash_traversal(void) +{ + TEST_ASSERT_FALSE(XModemAdapter::isValidFilename("..\\secret")); + TEST_ASSERT_FALSE(XModemAdapter::isValidFilename("..\\..\\Windows\\System32\\drivers\\etc\\hosts")); + TEST_ASSERT_FALSE(XModemAdapter::isValidFilename("dir\\..\\..\\x")); + TEST_ASSERT_FALSE(XModemAdapter::isValidFilename("dir/..\\x")); + TEST_ASSERT_FALSE(XModemAdapter::isValidFilename("dir\\..")); +} + +void test_xmodem_rejects_drive_qualified(void) +{ + TEST_ASSERT_FALSE(XModemAdapter::isValidFilename("C:\\Windows\\System32\\x")); + TEST_ASSERT_FALSE(XModemAdapter::isValidFilename("C:/Windows/System32/x")); + TEST_ASSERT_FALSE(XModemAdapter::isValidFilename("c:relative.txt")); +} + void test_xmodem_rejects_empty(void) { TEST_ASSERT_FALSE(XModemAdapter::isValidFilename("")); @@ -36,6 +52,9 @@ void test_xmodem_allows_legit_paths(void) // ".." only inside a name (not a whole component) is a valid filename, not traversal. TEST_ASSERT_TRUE(XModemAdapter::isValidFilename("my..file")); TEST_ASSERT_TRUE(XModemAdapter::isValidFilename("...")); + // A colon that cannot form a drive qualifier is a legal filename on the POSIX daemon. + TEST_ASSERT_TRUE(XModemAdapter::isValidFilename("1:30pm.txt")); + TEST_ASSERT_TRUE(XModemAdapter::isValidFilename("dir/1:30pm.txt")); } #endif // FSCom @@ -46,6 +65,8 @@ void setup() UNITY_BEGIN(); #ifdef FSCom RUN_TEST(test_xmodem_rejects_dotdot_traversal); + RUN_TEST(test_xmodem_rejects_backslash_traversal); + RUN_TEST(test_xmodem_rejects_drive_qualified); RUN_TEST(test_xmodem_rejects_empty); RUN_TEST(test_xmodem_allows_legit_paths); #endif diff --git a/userPrefs.jsonc b/userPrefs.jsonc index 66ee623c6..639ac6e28 100644 --- a/userPrefs.jsonc +++ b/userPrefs.jsonc @@ -44,6 +44,7 @@ // "USERPREFS_LORACONFIG_CHANNEL_NUM": "31", // "USERPREFS_LORACONFIG_TX_POWER": "0", // "USERPREFS_LORACONFIG_MODEM_PRESET": "meshtastic_Config_LoRaConfig_ModemPreset_SHORT_FAST", + // "USERPREFS_CONFIG_SECURITY_PACKET_SIGNATURE_POLICY": "meshtastic_Config_SecurityConfig_PacketSignaturePolicy_PACKET_SIGNATURE_POLICY_BALANCED", // Authenticity policy applied to remotely received packets. // "USERPREFS_USE_ADMIN_KEY_0": "{ 0xcd, 0xc0, 0xb4, 0x3c, 0x53, 0x24, 0xdf, 0x13, 0xca, 0x5a, 0xa6, 0x0c, 0x0d, 0xec, 0x85, 0x5a, 0x4c, 0xf6, 0x1a, 0x96, 0x04, 0x1a, 0x3e, 0xfc, 0xbb, 0x8e, 0x33, 0x71, 0xe5, 0xfc, 0xff, 0x3c }", // "USERPREFS_USE_ADMIN_KEY_1": "{}", // "USERPREFS_USE_ADMIN_KEY_2": "{}", diff --git a/variants/esp32/chatter2/platformio.ini b/variants/esp32/chatter2/platformio.ini index 0da8e94db..6913c2122 100644 --- a/variants/esp32/chatter2/platformio.ini +++ b/variants/esp32/chatter2/platformio.ini @@ -12,4 +12,4 @@ build_flags = lib_deps = ${esp32_base.lib_deps} # renovate: datasource=custom.pio depName=LovyanGFX packageName=lovyan03/library/LovyanGFX - lovyan03/LovyanGFX@1.2.25 + lovyan03/LovyanGFX@1.2.26 diff --git a/variants/esp32/esp32-common.ini b/variants/esp32/esp32-common.ini index 571fabfcc..72cc1966e 100644 --- a/variants/esp32/esp32-common.ini +++ b/variants/esp32/esp32-common.ini @@ -5,7 +5,9 @@ custom_esp32_kind = custom_mtjson_part = platform = # TODO renovate - https://github.com/pioarduino/platform-espressif32/releases/download/55.03.39/platform-espressif32.zip + ; Using forked pioarduino to fix --force-reinstall CI issue + https://github.com/meshtastic/pioarduino-platform-espressif32/archive/refs/heads/55.03.39.zip + ; https://github.com/pioarduino/platform-espressif32/releases/download/55.03.39/platform-espressif32.zip ; https://github.com/pioarduino/platform-espressif32.git#develop platform_packages = # renovate: datasource=custom.pio depName=platformio/tool-mklittlefs packageName=platformio/tool/tool-mklittlefs @@ -70,7 +72,7 @@ lib_deps = https://github.com/mverch67/libpax/archive/6f52ee989301cdabaeef00bcbf93bff55708ce2f.zip # renovate: datasource=custom.pio depName=XPowersLib packageName=lewisxhe/library/XPowersLib lewisxhe/XPowersLib@0.3.3 - # renovate: datasource=git-refs depName=meshtastic/Crypto packageName=https://github.com/meshtastic/Crypto gitBranch=master + # renovate: datasource=git-refs depName=meshtastic/Crypto packageName=https://github.com/meshtastic/Crypto gitBranch=main https://github.com/meshtastic/Crypto/archive/591ff9a690e8168ccb7a36abde8d7783e448d395.zip lib_ignore = diff --git a/variants/esp32/esp32.ini b/variants/esp32/esp32.ini index 79b421dd5..2c21f81bf 100644 --- a/variants/esp32/esp32.ini +++ b/variants/esp32/esp32.ini @@ -19,7 +19,6 @@ build_flags = -DMESHTASTIC_EXCLUDE_ACCELEROMETER=1 -DMESHTASTIC_EXCLUDE_PAXCOUNTER=1 -DMESHTASTIC_EXCLUDE_WEBSERVER=1 - -DMESHTASTIC_EXCLUDE_RANGETEST=1 ; HACK HACK HACK this is just a proof of concept ESP32+NimBLE but these ; includes need to be done properly somehow ; Quotes keep Windows packages_dir backslashes intact through POSIX flag parsing @@ -56,5 +55,5 @@ lib_deps = https://github.com/mverch67/libpax/archive/6f52ee989301cdabaeef00bcbf93bff55708ce2f.zip # renovate: datasource=custom.pio depName=XPowersLib packageName=lewisxhe/library/XPowersLib lewisxhe/XPowersLib@0.3.3 - # renovate: datasource=git-refs depName=meshtastic/Crypto packageName=https://github.com/meshtastic/Crypto gitBranch=master + # renovate: datasource=git-refs depName=meshtastic/Crypto packageName=https://github.com/meshtastic/Crypto gitBranch=main https://github.com/meshtastic/Crypto/archive/591ff9a690e8168ccb7a36abde8d7783e448d395.zip diff --git a/variants/esp32/m5stack_core/platformio.ini b/variants/esp32/m5stack_core/platformio.ini index 23346bdab..fbdbada5e 100644 --- a/variants/esp32/m5stack_core/platformio.ini +++ b/variants/esp32/m5stack_core/platformio.ini @@ -35,4 +35,4 @@ lib_ignore = lib_deps = ${esp32_base.lib_deps} # renovate: datasource=custom.pio depName=LovyanGFX packageName=lovyan03/library/LovyanGFX - lovyan03/LovyanGFX@1.2.25 + lovyan03/LovyanGFX@1.2.26 diff --git a/variants/esp32/rak11200/platformio.ini b/variants/esp32/rak11200/platformio.ini index b48d638fb..2abb9730f 100644 --- a/variants/esp32/rak11200/platformio.ini +++ b/variants/esp32/rak11200/platformio.ini @@ -18,6 +18,5 @@ build_flags = -I variants/esp32/rak11200 -DMESHTASTIC_EXCLUDE_WEBSERVER=1 -DMESHTASTIC_EXCLUDE_PAXCOUNTER=1 - -DMESHTASTIC_EXCLUDE_RANGETEST=1 -DMESHTASTIC_EXCLUDE_MQTT=1 upload_speed = 115200 diff --git a/variants/esp32/wiphone/platformio.ini b/variants/esp32/wiphone/platformio.ini index 401c3868d..4bc8bf6bc 100644 --- a/variants/esp32/wiphone/platformio.ini +++ b/variants/esp32/wiphone/platformio.ini @@ -11,7 +11,7 @@ build_flags = lib_deps = ${esp32_base.lib_deps} # renovate: datasource=custom.pio depName=LovyanGFX packageName=lovyan03/library/LovyanGFX - lovyan03/LovyanGFX@1.2.25 + lovyan03/LovyanGFX@1.2.26 # renovate: datasource=custom.pio depName=SX1509 IO Expander packageName=sparkfun/library/SX1509 IO Expander sparkfun/SX1509 IO Expander@3.0.6 # renovate: datasource=custom.pio depName=APA102 packageName=pololu/library/APA102 diff --git a/variants/esp32p4/esp32p4.ini b/variants/esp32p4/esp32p4.ini index 85abaee5c..5ad136806 100644 --- a/variants/esp32p4/esp32p4.ini +++ b/variants/esp32p4/esp32p4.ini @@ -104,7 +104,7 @@ lib_deps = ${networking_extra.lib_deps} ${environmental_base.lib_deps} ${radiolib_base.lib_deps} - # renovate: datasource=git-refs depName=meshtastic/Crypto packageName=https://github.com/meshtastic/Crypto gitBranch=master + # renovate: datasource=git-refs depName=meshtastic/Crypto packageName=https://github.com/meshtastic/Crypto gitBranch=main https://github.com/meshtastic/Crypto/archive/591ff9a690e8168ccb7a36abde8d7783e448d395.zip # renovate: datasource=git-refs depName=meshtastic-ESP32_Codec2 packageName=https://github.com/meshtastic/ESP32_Codec2 gitBranch=master https://github.com/meshtastic/ESP32_Codec2/archive/633326c78ac251c059ab3a8c430fcdf25b41672f.zip diff --git a/variants/esp32s3/elecrow_panel/platformio.ini b/variants/esp32s3/elecrow_panel/platformio.ini index 1e8efd5b9..70858f8a0 100644 --- a/variants/esp32s3/elecrow_panel/platformio.ini +++ b/variants/esp32s3/elecrow_panel/platformio.ini @@ -51,7 +51,7 @@ lib_deps = ${esp32s3_base.lib_deps} # renovate: datasource=custom.pio depName=TCA9534 packageName=hideakitai/library/TCA9534 hideakitai/TCA9534@0.1.1 # renovate: datasource=custom.pio depName=LovyanGFX packageName=lovyan03/library/LovyanGFX - lovyan03/LovyanGFX@1.2.25 + lovyan03/LovyanGFX@1.2.26 [crowpanel_small_esp32s3_base] ; 2.4, 2.8, 3.5 inch extends = crowpanel_base diff --git a/variants/esp32s3/heltec_v4/platformio.ini b/variants/esp32s3/heltec_v4/platformio.ini index 2844d0aeb..04a7bedcd 100644 --- a/variants/esp32s3/heltec_v4/platformio.ini +++ b/variants/esp32s3/heltec_v4/platformio.ini @@ -134,6 +134,6 @@ build_flags = lib_deps = ${heltec_v4_base.lib_deps} ${device-ui_base.lib_deps} # renovate: datasource=custom.pio depName=LovyanGFX packageName=lovyan03/library/LovyanGFX - lovyan03/LovyanGFX@1.2.25 + lovyan03/LovyanGFX@1.2.26 # renovate: datasource=git-refs depName=Quency-D_chsc6x packageName=https://github.com/Quency-D/chsc6x gitBranch=master https://github.com/Quency-D/chsc6x/archive/3b2b6cebf3177b3e2c33d06e07909b0b10159516.zip diff --git a/variants/esp32s3/heltec_v4_r8/platformio.ini b/variants/esp32s3/heltec_v4_r8/platformio.ini index d545b55dd..bb3ee2ee3 100644 --- a/variants/esp32s3/heltec_v4_r8/platformio.ini +++ b/variants/esp32s3/heltec_v4_r8/platformio.ini @@ -140,6 +140,6 @@ build_flags = lib_deps = ${heltec_v4_r8_base.lib_deps} ${device-ui_base.lib_deps} # renovate: datasource=custom.pio depName=LovyanGFX packageName=lovyan03/library/LovyanGFX - lovyan03/LovyanGFX@1.2.25 + lovyan03/LovyanGFX@1.2.26 # renovate: datasource=git-refs depName=Quency-D_chsc6x packageName=https://github.com/Quency-D/chsc6x gitBranch=master https://github.com/Quency-D/chsc6x/archive/3b2b6cebf3177b3e2c33d06e07909b0b10159516.zip \ No newline at end of file diff --git a/variants/esp32s3/heltec_wireless_tracker/platformio.ini b/variants/esp32s3/heltec_wireless_tracker/platformio.ini index 3db282214..e835b9435 100644 --- a/variants/esp32s3/heltec_wireless_tracker/platformio.ini +++ b/variants/esp32s3/heltec_wireless_tracker/platformio.ini @@ -26,4 +26,4 @@ build_flags = lib_deps = ${esp32s3_base.lib_deps} # renovate: datasource=custom.pio depName=LovyanGFX packageName=lovyan03/library/LovyanGFX - lovyan03/LovyanGFX@1.2.25 + lovyan03/LovyanGFX@1.2.26 diff --git a/variants/esp32s3/heltec_wireless_tracker_V1_0/platformio.ini b/variants/esp32s3/heltec_wireless_tracker_V1_0/platformio.ini index 575109abe..fa7835bbf 100644 --- a/variants/esp32s3/heltec_wireless_tracker_V1_0/platformio.ini +++ b/variants/esp32s3/heltec_wireless_tracker_V1_0/platformio.ini @@ -22,4 +22,4 @@ build_flags = lib_deps = ${esp32s3_base.lib_deps} # renovate: datasource=custom.pio depName=LovyanGFX packageName=lovyan03/library/LovyanGFX - lovyan03/LovyanGFX@1.2.25 + lovyan03/LovyanGFX@1.2.26 diff --git a/variants/esp32s3/heltec_wireless_tracker_v2/platformio.ini b/variants/esp32s3/heltec_wireless_tracker_v2/platformio.ini index fc3935be3..831f0ca79 100644 --- a/variants/esp32s3/heltec_wireless_tracker_v2/platformio.ini +++ b/variants/esp32s3/heltec_wireless_tracker_v2/platformio.ini @@ -21,4 +21,4 @@ build_flags = lib_deps = ${esp32s3_base.lib_deps} # renovate: datasource=custom.pio depName=LovyanGFX packageName=lovyan03/library/LovyanGFX - lovyan03/LovyanGFX@1.2.25 + lovyan03/LovyanGFX@1.2.26 diff --git a/variants/esp32s3/mesh-tab/platformio.ini b/variants/esp32s3/mesh-tab/platformio.ini index 0b111126b..661f6aac8 100644 --- a/variants/esp32s3/mesh-tab/platformio.ini +++ b/variants/esp32s3/mesh-tab/platformio.ini @@ -55,7 +55,7 @@ lib_deps = ${esp32s3_base.lib_deps} ${device-ui_base.lib_deps} # renovate: datasource=custom.pio depName=LovyanGFX packageName=lovyan03/library/LovyanGFX - lovyan03/LovyanGFX@1.2.25 + lovyan03/LovyanGFX@1.2.26 [mesh_tab_xpt2046] extends = mesh_tab_base diff --git a/variants/esp32s3/meshnology-w12/pins_arduino.h b/variants/esp32s3/meshnology-w12/pins_arduino.h new file mode 100644 index 000000000..b7f2c1e4c --- /dev/null +++ b/variants/esp32s3/meshnology-w12/pins_arduino.h @@ -0,0 +1,60 @@ +// Meshnology W12 - shadows the generic esp32s3 pins, minus RGB_BUILTIN/LED_BUILTIN so +// digitalWrite() does not pull in the RMT RGB HAL, which fails to link in this build. +#ifndef Pins_Arduino_h +#define Pins_Arduino_h + +#include "soc/soc_caps.h" +#include + +#define USB_VID 0x303a +#define USB_PID 0x1001 + +static const uint8_t TX = 43; +static const uint8_t RX = 44; + +static const uint8_t SDA = 17; +static const uint8_t SCL = 18; + +// LR2021 SPI bus +static const uint8_t SS = 8; +static const uint8_t MOSI = 10; +static const uint8_t MISO = 11; +static const uint8_t SCK = 9; + +static const uint8_t A0 = 1; +static const uint8_t A1 = 2; +static const uint8_t A2 = 3; +static const uint8_t A3 = 4; +static const uint8_t A4 = 5; +static const uint8_t A5 = 6; +static const uint8_t A6 = 7; +static const uint8_t A7 = 8; +static const uint8_t A8 = 9; +static const uint8_t A9 = 10; +static const uint8_t A10 = 11; +static const uint8_t A11 = 12; +static const uint8_t A12 = 13; +static const uint8_t A13 = 14; +static const uint8_t A14 = 15; +static const uint8_t A15 = 16; +static const uint8_t A16 = 17; +static const uint8_t A17 = 18; +static const uint8_t A18 = 19; +static const uint8_t A19 = 20; + +static const uint8_t T1 = 1; +static const uint8_t T2 = 2; +static const uint8_t T3 = 3; +static const uint8_t T4 = 4; +static const uint8_t T5 = 5; +static const uint8_t T6 = 6; +static const uint8_t T7 = 7; +static const uint8_t T8 = 8; +static const uint8_t T9 = 9; +static const uint8_t T10 = 10; +static const uint8_t T11 = 11; +static const uint8_t T12 = 12; +static const uint8_t T13 = 13; +static const uint8_t T14 = 14; + +#endif /* Pins_Arduino_h */ diff --git a/variants/esp32s3/meshnology-w12/platformio.ini b/variants/esp32s3/meshnology-w12/platformio.ini new file mode 100644 index 000000000..01574b573 --- /dev/null +++ b/variants/esp32s3/meshnology-w12/platformio.ini @@ -0,0 +1,26 @@ +; Meshnology W12 "WiFi LoRa 32 V5" - ESP32-S3R8 + LR2021 dual-band LoRa + SSD1315 OLED +; No MESHNOLOGY_W12 hardware model exists in the protobufs yet, so this reports PRIVATE_HW. +[env:meshnology_w12] +custom_meshtastic_hw_model = 255 +custom_meshtastic_hw_model_slug = MESHNOLOGY_W12 +custom_meshtastic_architecture = esp32-s3 +custom_meshtastic_actively_supported = true +custom_meshtastic_support_level = 1 +custom_meshtastic_display_name = Meshnology W12 +custom_meshtastic_requires_dfu = true +custom_meshtastic_partition_scheme = 16MB + +extends = esp32s3_base +board = esp32-s3-devkitc-1 +board_level = pr +; ESP32-S3R8: 8 MB OPI PSRAM, 16 MB QIO flash +board_build.partitions = default_16MB.csv +board_upload.flash_size = 16MB +board_build.flash_mode = qio +board_build.psram_type = opi +board_build.arduino.memory_type = qio_opi +build_flags = + ${esp32s3_base.build_flags} + -D MESHNOLOGY_W12 + -D ARDUINO_USB_CDC_ON_BOOT=1 + -I variants/esp32s3/meshnology-w12 diff --git a/variants/esp32s3/meshnology-w12/variant.h b/variants/esp32s3/meshnology-w12/variant.h new file mode 100644 index 000000000..c412e0222 --- /dev/null +++ b/variants/esp32s3/meshnology-w12/variant.h @@ -0,0 +1,45 @@ +#pragma once + +// Meshnology W12 "WiFi LoRa 32 V5" - ESP32-S3R8 (8 MB PSRAM) + 16 MB flash + Semtech LR2021 +// dual-band LoRa (sub-GHz + 2.4 GHz) + SSD1315 0.96" OLED. Heltec V3-style pinout. + +// โ”€โ”€โ”€ OLED โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +// SSD1315 (SSD1306-compatible) 128x64 at 0x3C +#define USE_SSD1306 +#define I2C_SDA 17 +#define I2C_SCL 18 + +// Powers the OLED/peripheral rail; active LOW +#define VEXT_ENABLE 45 + +// โ”€โ”€โ”€ User input โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +#define BUTTON_PIN 0 // BOOT doubles as user button + +// โ”€โ”€โ”€ Battery โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +// GPIO2 monitors a second (solar/VUSB) divider and is not used here +#define BATTERY_PIN 1 +#define ADC_CHANNEL ADC_CHANNEL_0 // GPIO1 = ADC1_CH0 +#define ADC_MULTIPLIER 2.0 + +// โ”€โ”€โ”€ LoRa radio โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +// RF switching is hardwired to the radio's CTX/CPS pins, and the external PA enables +// (IO3 sub-GHz, IO4 2.4GHz) are pulled high, so no RF-switch DIO table is needed. +#define USE_LR2021 +#define LORA_SCK 9 +#define LORA_MISO 11 +#define LORA_MOSI 10 +#define LORA_CS 8 +#define LORA_DIO1 14 // radio DIO8; also the LoRa sleep-wake pin + +#define LR2021_SPI_NSS_PIN LORA_CS +#define LR2021_IRQ_PIN LORA_DIO1 +#define LR2021_NRESET_PIN 12 +#define LR2021_BUSY_PIN 13 +// Must be 8, not RadioLib's default of 5: DIO5 is not bonded out here, so the default +// points every interrupt at a floating pin and scanChannel() never returns. +#define LR2021_IRQ_DIO_NUM 8 +// DIO7 -> IO7 is a second, unused IRQ line. Plain crystal on XTA/XTB, so no TCXO voltage. + +// โ”€โ”€โ”€ Not wired up yet โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +// RGB LED on GPIO46 stays off: HAS_NEOPIXEL pulls in the RMT HAL, which fails to link here. +// L76K GNSS header (RX=39, TX=38, CTRL=48 active low, WAKE=40, PPS=41, RST=42) is unpopulated. diff --git a/variants/esp32s3/picomputer-s3/platformio.ini b/variants/esp32s3/picomputer-s3/platformio.ini index f7aabc126..ed200077e 100644 --- a/variants/esp32s3/picomputer-s3/platformio.ini +++ b/variants/esp32s3/picomputer-s3/platformio.ini @@ -25,7 +25,7 @@ build_flags = lib_deps = ${esp32s3_base.lib_deps} # renovate: datasource=custom.pio depName=LovyanGFX packageName=lovyan03/library/LovyanGFX - lovyan03/LovyanGFX@1.2.25 + lovyan03/LovyanGFX@1.2.26 build_src_filter = ${esp32s3_base.build_src_filter} diff --git a/variants/esp32s3/rak_wismesh_tap_v2/platformio.ini b/variants/esp32s3/rak_wismesh_tap_v2/platformio.ini index 3cf7bf8dd..00afff511 100644 --- a/variants/esp32s3/rak_wismesh_tap_v2/platformio.ini +++ b/variants/esp32s3/rak_wismesh_tap_v2/platformio.ini @@ -37,7 +37,7 @@ build_flags = lib_deps = ${esp32s3_base.lib_deps} # renovate: datasource=custom.pio depName=LovyanGFX packageName=lovyan03/library/LovyanGFX - lovyan03/LovyanGFX@1.2.25 + lovyan03/LovyanGFX@1.2.26 [env:rak_wismesh_tap_v2-tft] extends = env:rak_wismesh_tap_v2 diff --git a/variants/esp32s3/t-deck/platformio.ini b/variants/esp32s3/t-deck/platformio.ini index 4983fc5c5..04efbb214 100644 --- a/variants/esp32s3/t-deck/platformio.ini +++ b/variants/esp32s3/t-deck/platformio.ini @@ -29,7 +29,7 @@ build_flags = ${esp32s3_base.build_flags} lib_deps = ${esp32s3_base.lib_deps} # renovate: datasource=custom.pio depName=LovyanGFX packageName=lovyan03/library/LovyanGFX - lovyan03/LovyanGFX@1.2.25 + lovyan03/LovyanGFX@1.2.26 # renovate: datasource=git-refs depName=ESP8266Audio packageName=https://github.com/meshtastic/ESP8266Audio gitBranch=meshtastic-2.0.0-dacfix https://github.com/meshtastic/ESP8266Audio/archive/343024632ee78d6216907b2353fc943a62422d80.zip # renovate: datasource=custom.pio depName=ESP8266SAM packageName=earlephilhower/library/ESP8266SAM diff --git a/variants/esp32s3/t-watch-s3/platformio.ini b/variants/esp32s3/t-watch-s3/platformio.ini index e1f6814e2..1565a3f7e 100644 --- a/variants/esp32s3/t-watch-s3/platformio.ini +++ b/variants/esp32s3/t-watch-s3/platformio.ini @@ -22,7 +22,7 @@ build_flags = ${esp32s3_base.build_flags} lib_deps = ${esp32s3_base.lib_deps} # renovate: datasource=custom.pio depName=LovyanGFX packageName=lovyan03/library/LovyanGFX - lovyan03/LovyanGFX@1.2.25 + lovyan03/LovyanGFX@1.2.26 # renovate: datasource=custom.pio depName=SensorLib packageName=lewisxhe/library/SensorLib lewisxhe/SensorLib@0.3.4 # renovate: datasource=custom.pio depName=Adafruit DRV2605 packageName=adafruit/library/Adafruit DRV2605 Library diff --git a/variants/esp32s3/t5s3_epaper/platformio.ini b/variants/esp32s3/t5s3_epaper/platformio.ini index 781b382b5..16997945f 100644 --- a/variants/esp32s3/t5s3_epaper/platformio.ini +++ b/variants/esp32s3/t5s3_epaper/platformio.ini @@ -28,6 +28,14 @@ lib_deps = https://github.com/mverch67/BQ27220/archive/07d92be846abd8a0258a50c23198dac0858b22ed.zip https://github.com/mverch67/FastEPD/archive/0df1bff329b6fc782e062f611758880762340647.zip +; This board's 3.3V octal (AP_3v3) 8MB PSRAM is unreliable at the qio_opi default 80MHz +; (Total PSRAM reads 0); 40MHz makes it enumerate. Forces a from-source build as no +; precompiled 40MHz-octal libs exist; bandwidth is immaterial for an e-paper node. +custom_sdkconfig = + ${esp32s3_base.custom_sdkconfig} + CONFIG_SPIRAM_SPEED_40M=y + CONFIG_SPIRAM_SPEED=40 + [env:t5s3_epaper_inkhud] extends = t5s3_epaper_base, inkhud board_level = extra diff --git a/variants/esp32s3/tlora-pager/platformio.ini b/variants/esp32s3/tlora-pager/platformio.ini index 607526ba3..ad326220b 100644 --- a/variants/esp32s3/tlora-pager/platformio.ini +++ b/variants/esp32s3/tlora-pager/platformio.ini @@ -33,7 +33,7 @@ build_flags = ${esp32s3_base.build_flags} lib_deps = ${esp32s3_base.lib_deps} # renovate: datasource=custom.pio depName=LovyanGFX packageName=lovyan03/library/LovyanGFX - lovyan03/LovyanGFX@1.2.25 + lovyan03/LovyanGFX@1.2.26 # renovate: datasource=git-refs depName=ESP8266Audio packageName=https://github.com/meshtastic/ESP8266Audio gitBranch=meshtastic-2.0.0-dacfix https://github.com/meshtastic/ESP8266Audio/archive/343024632ee78d6216907b2353fc943a62422d80.zip # renovate: datasource=custom.pio depName=ESP8266SAM packageName=earlephilhower/library/ESP8266SAM diff --git a/variants/esp32s3/tracksenger/platformio.ini b/variants/esp32s3/tracksenger/platformio.ini index 20ef9e30e..6cfe932fd 100644 --- a/variants/esp32s3/tracksenger/platformio.ini +++ b/variants/esp32s3/tracksenger/platformio.ini @@ -22,7 +22,7 @@ build_flags = lib_deps = ${esp32s3_base.lib_deps} # renovate: datasource=custom.pio depName=LovyanGFX packageName=lovyan03/library/LovyanGFX - lovyan03/LovyanGFX@1.2.25 + lovyan03/LovyanGFX@1.2.26 [env:tracksenger-lcd] custom_meshtastic_hw_model = 48 @@ -48,7 +48,7 @@ build_flags = lib_deps = ${esp32s3_base.lib_deps} # renovate: datasource=custom.pio depName=LovyanGFX packageName=lovyan03/library/LovyanGFX - lovyan03/LovyanGFX@1.2.25 + lovyan03/LovyanGFX@1.2.26 [env:tracksenger-oled] custom_meshtastic_hw_model = 48 diff --git a/variants/esp32s3/unphone/platformio.ini b/variants/esp32s3/unphone/platformio.ini index 48317db5e..6b64bd960 100644 --- a/variants/esp32s3/unphone/platformio.ini +++ b/variants/esp32s3/unphone/platformio.ini @@ -36,7 +36,7 @@ build_src_filter = lib_deps = ${esp32s3_base.lib_deps} # renovate: datasource=custom.pio depName=LovyanGFX packageName=lovyan03/library/LovyanGFX - lovyan03/LovyanGFX@1.2.25 + lovyan03/LovyanGFX@1.2.26 # TODO renovate https://gitlab.com/hamishcunningham/unphonelibrary#meshtastic@9.0.0 https://gitlab.com/hamishcunningham/unphonelibrary/-/archive/meshtastic/unphonelibrary-meshtastic.zip diff --git a/variants/native/portduino.ini b/variants/native/portduino.ini index 044f64166..5997cf1fb 100644 --- a/variants/native/portduino.ini +++ b/variants/native/portduino.ini @@ -2,7 +2,7 @@ [portduino_base] platform = # renovate: datasource=git-refs depName=platform-native packageName=https://github.com/meshtastic/platform-native gitBranch=develop - https://github.com/meshtastic/platform-native/archive/61067ac3774e4fe27aa9762c72cebea507f116c8.zip + https://github.com/meshtastic/platform-native/archive/86c62edfb7084d11669aa255411a70580e83823b.zip framework = arduino build_src_filter = @@ -23,12 +23,12 @@ lib_deps = ${networking_extra.lib_deps} ${radiolib_base.lib_deps} ${environmental_base.lib_deps} - # renovate: datasource=git-refs depName=meshtastic/Crypto packageName=https://github.com/meshtastic/Crypto gitBranch=master + # renovate: datasource=git-refs depName=meshtastic/Crypto packageName=https://github.com/meshtastic/Crypto gitBranch=main https://github.com/meshtastic/Crypto/archive/591ff9a690e8168ccb7a36abde8d7783e448d395.zip # renovate: datasource=custom.pio depName=LovyanGFX packageName=lovyan03/library/LovyanGFX - lovyan03/LovyanGFX@1.2.25 - ; # renovate: datasource=git-refs depName=libch341-spi-userspace packageName=https://github.com/pine64/libch341-spi-userspace gitBranch=main - https://github.com/pine64/libch341-spi-userspace/archive/2e5ff751d0c39667993df672cb683740ed5c9394.zip + lovyan03/LovyanGFX@1.2.26 + ; # renovate: datasource=git-refs depName=libch341-spi-userspace packageName=https://github.com/meshtastic/libch341-spi-userspace gitBranch=main + https://github.com/meshtastic/libch341-spi-userspace/archive/03bf505d6e5904092c1c389c45b01098f7a302fe.zip # renovate: datasource=custom.pio depName=adafruit/Adafruit seesaw Library packageName=adafruit/library/Adafruit seesaw Library adafruit/Adafruit seesaw Library@1.7.9 # renovate: datasource=git-refs depName=RAK12034-BMX160 packageName=https://github.com/RAKWireless/RAK12034-BMX160 gitBranch=main @@ -56,7 +56,6 @@ build_flags_common = -luv -std=gnu17 -std=gnu++17 - -DBASEUI_HAS_GAMES=1 -DMAX_TFT_COLOR_REGIONS=64 build_flags = diff --git a/variants/native/portduino/platformio.ini b/variants/native/portduino/platformio.ini index 5694a0d3b..22ac4ff5f 100644 --- a/variants/native/portduino/platformio.ini +++ b/variants/native/portduino/platformio.ini @@ -251,6 +251,83 @@ build_flags = ${env:native-macos.build_flags} build_src_filter = ${env:native-macos.build_src_filter} lib_ignore = ${env:native-macos.lib_ignore} +; --------------------------------------------------------------------------- +; Native build for Windows (x86_64) via the MSYS2 UCRT64 MinGW-w64 toolchain. +; Headless meshtasticd.exe running in SimRadio mode (`-s`). No BlueZ, libgpiod or +; Linux I2C, and no UDP multicast: the framework's AsyncUDP.cpp is BSD sockets, +; so HAS_UDP_MULTICAST stays unset here as it does on macOS. +; +; MSVC is not an option: platform-native's builder calls env.Tool("gcc") and the +; firmware builds as gnu17/gnu++17 with GNU extensions throughout. +; +; Prerequisites (MSYS2, https://www.msys2.org/): +; pacman -S --needed mingw-w64-ucrt-x86_64-{gcc,pkgconf,yaml-cpp,libuv,jsoncpp,openssl,libusb} +; +; argp is not packaged for MSYS2's mingw environments (msys/libargp links the +; msys-2.0.dll emulation layer and can't be used for a native binary), yet +; Arduino.h includes and main.cpp calls argp_parse(). Build it once from +; source, the same dependency macOS meets with `brew install argp-standalone`: +; git clone https://github.com/tom42/argp-standalone +; cd argp-standalone && cmake -G Ninja -B build -DCMAKE_BUILD_TYPE=Release . +; cmake --build build +; cp include/argp-standalone/argp.h /ucrt64/include/argp.h ; ships no install() rules +; cp build/src/libargp-standalone.a /ucrt64/lib/libargp.a +; +; Build from any shell with /ucrt64/bin on PATH: +; pio run -e native-windows +; .pio/build/native-windows/meshtasticd.exe -s +; --------------------------------------------------------------------------- +[env:native-windows] +extends = native_base +build_flags = ${portduino_base.build_flags_common} + -I variants/native/portduino + ; Our drop-in libpinedio-usb.h, replacing the libusb one: libusb can only reach + ; a device bound to WinUSB, which means Zadig on every machine. See + ; src/platform/portduino/windows/libpinedio_ch341dll.c. + -I src/platform/portduino/windows/include + -largp + -lws2_32 ; GpsdSerial.cpp's TCP client + -lbcrypt ; BCryptGenRandom() in HardwareRNG.cpp + -liphlpapi ; GetAdaptersAddresses() host-MAC fallback in PortduinoGlue.cpp + ; libch341's libpinedio-usb.h pulls in libusb.h and so , which + ; collides with the Arduino API: winuser.h's `typedef struct tagINPUT INPUT` vs + ; the PinMode enumerator, and rpcndr.h's `typedef unsigned char boolean` vs + ; Arduino's `typedef bool boolean`. NOUSER and WIN32_LEAN_AND_MEAN keep those + ; headers out, NOMINMAX stops min/max being macroed over std::min/std::max. + -DWIN32_LEAN_AND_MEAN + -DNOMINMAX + -DNOUSER + -DNOGDI + ; yaml-cpp declares its API __declspec(dllimport) unless told the link is + ; static, leaving every YAML symbol undefined as __imp_*. + -DYAML_CPP_STATIC_DEFINE + ; Headless: variant.h would otherwise default HAS_SCREEN to 1 and pull in the + ; screen renderer; EXCLUDE_SCREEN gates the `screen->...` hooks in the sensors. + -DHAS_SCREEN=0 + -DMESHTASTIC_EXCLUDE_SCREEN=1 + !pkg-config --cflags --libs openssl --silence-errors || : +build_unflags = + -fPIC ; ignored on Windows, where all code is position-independent +; Static link, so meshtasticd.exe stands alone and can't be hijacked by a stray +; System32 DLL. PlatformIO only feeds build_flags to the compile step, hence the script. +extra_scripts = + ${env.extra_scripts} + post:extra_scripts/windows_link_flags.py +; LinuxInput drives evdev; Panel_sdl/TFTDisplay pull LovyanGFX. Neither is needed +; for the headless build. +build_src_filter = ${native_base.build_src_filter} + - + - + - + - +; LovyanGFX includes and is only needed by the TFT variants. The pine64 +; libch341 is the libusb backend that libpinedio_ch341dll.c replaces; keeping both +; would duplicate every pinedio_* symbol. +lib_ignore = + ${portduino_base.lib_ignore} + LovyanGFX + Pine libch341-spi Userspace library + ; --------------------------------------------------------------------------- ; WASM (Emscripten) - the portduino node compiled to WebAssembly, driving a real ; LoRa radio over WebUSB through a CH341 (src/platform/portduino/wasm/). The same @@ -324,7 +401,7 @@ lib_ldf_mode = chain+ lib_deps = ${env.lib_deps} ${radiolib_base.lib_deps} - # renovate: datasource=git-refs depName=meshtastic/Crypto packageName=https://github.com/meshtastic/Crypto gitBranch=master + # renovate: datasource=git-refs depName=meshtastic/Crypto packageName=https://github.com/meshtastic/Crypto gitBranch=main https://github.com/meshtastic/Crypto/archive/591ff9a690e8168ccb7a36abde8d7783e448d395.zip # renovate: datasource=custom.pio depName=Melopero RV3028 packageName=melopero/library/Melopero RV3028 melopero/Melopero RV3028@1.2.0 diff --git a/variants/nrf52840/monteops_hw1/platformio.ini b/variants/nrf52840/monteops_hw1/platformio.ini index e783e130e..b14db28d1 100644 --- a/variants/nrf52840/monteops_hw1/platformio.ini +++ b/variants/nrf52840/monteops_hw1/platformio.ini @@ -11,7 +11,7 @@ lib_deps = ${nrf52840_base.lib_deps} ${networking_base.lib_deps} # renovate: datasource=github-tags depName=RAK13800-W5100S packageName=RAKWireless/RAK13800-W5100S - https://github.com/RAKWireless/RAK13800-W5100S/archive/1.0.2.zip + https://github.com/RAKWireless/RAK13800-W5100S/archive/1.0.3.zip debug_tool = jlink ; If not set we will default to uploading over serial (first it forces bootloader entry by talking 1200bps to cdcacm) ;upload_protocol = jlink diff --git a/variants/nrf52840/nrf52.ini b/variants/nrf52840/nrf52.ini index f989a48a4..471db3c8d 100644 --- a/variants/nrf52840/nrf52.ini +++ b/variants/nrf52840/nrf52.ini @@ -6,7 +6,7 @@ platform = extends = arduino_base platform_packages = ; our custom Git version with C++17 support in platform.txt - # renovate: datasource=git-refs depName=meshtastic/Adafruit_nRF52_Arduino packageName=meshtastic/Adafruit_nRF52_Arduino gitBranch=master + # renovate: datasource=git-refs depName=meshtastic/Adafruit_nRF52_Arduino packageName=https://github.com/meshtastic/Adafruit_nRF52_Arduino gitBranch=master platformio/framework-arduinoadafruitnrf52 @ https://github.com/meshtastic/Adafruit_nRF52_Arduino#0fd295f13203e93df19d578073646ec32f2bf45a ; Don't renovate toolchain-gccarmnoneeabi platformio/toolchain-gccarmnoneeabi@~1.90301.0 @@ -30,6 +30,13 @@ build_flags = ; 2048 words = 8 KB, validated on hardware. Value is in WORDS; requires the #ifndef guard ; from meshtastic/Adafruit_nRF52_Arduino#7 (harmless redefinition warning until it merges). -DLOOP_STACK_SZ=2048 + ; The Bluefruit BLE task runs the ENTIRE phone-API chain inline (toRadioWriteCb -> + ; handleToRadio -> admin set-config -> radio reconfigure -> LittleFS save): its stock + ; 5 KB (1280-word) stack overflows during pairing/first-sync, resetting mid-flash-write + ; and tearing LittleFS (auto-format -> total config/key wipe, critical fault #13). + ; Reproduced on Wio Tracker L1 @ develop 6908d27. Same rationale as LOOP_STACK_SZ above; + ; bluefruit.cpp's #ifndef guard makes the -D take effect without a framework patch. + -DCFG_BLE_TASK_STACKSIZE=2048 -DLFS_NO_ASSERT ; Disable LFS assertions , see https://github.com/meshtastic/firmware/pull/3818 -DMESHTASTIC_EXCLUDE_AUDIO=1 -DMESHTASTIC_EXCLUDE_PAXCOUNTER=1 @@ -58,7 +65,7 @@ build_src_filter = lib_deps= ${arduino_base.lib_deps} ${radiolib_base.lib_deps} - # renovate: datasource=github-tags depName=meshtastic/Crypto packageName=meshtastic/Crypto + # renovate: datasource=git-refs depName=meshtastic/Crypto packageName=https://github.com/meshtastic/Crypto gitBranch=main https://github.com/meshtastic/Crypto/archive/591ff9a690e8168ccb7a36abde8d7783e448d395.zip lib_ignore = diff --git a/variants/nrf52840/r1-neo/platformio.ini b/variants/nrf52840/r1-neo/platformio.ini index 0aaec2330..9d70d6bfd 100644 --- a/variants/nrf52840/r1-neo/platformio.ini +++ b/variants/nrf52840/r1-neo/platformio.ini @@ -24,7 +24,7 @@ lib_deps = ${nrf52840_base.lib_deps} ${networking_base.lib_deps} # renovate: datasource=github-tags depName=RAK13800-W5100S packageName=RAKWireless/RAK13800-W5100S - https://github.com/RAKWireless/RAK13800-W5100S/archive/1.0.2.zip + https://github.com/RAKWireless/RAK13800-W5100S/archive/1.0.3.zip # renovate: datasource=custom.pio depName=RAK NCP5623 RGB LED packageName=rakwireless/library/RAKwireless NCP5623 RGB LED library rakwireless/RAKwireless NCP5623 RGB LED library@1.0.3 # renovate: datasource=custom.pio depName=ArtronShop_RX8130CE packageName=artronshop/library/ArtronShop_RX8130CE diff --git a/variants/nrf52840/rak4631/platformio.ini b/variants/nrf52840/rak4631/platformio.ini index d0b776b3b..12c60ffdf 100644 --- a/variants/nrf52840/rak4631/platformio.ini +++ b/variants/nrf52840/rak4631/platformio.ini @@ -36,7 +36,7 @@ lib_deps = # renovate: datasource=custom.pio depName=Melopero RV3028 packageName=melopero/library/Melopero RV3028 melopero/Melopero RV3028@1.2.0 # renovate: datasource=github-tags depName=RAK13800-W5100S packageName=RAKWireless/RAK13800-W5100S - https://github.com/RAKWireless/RAK13800-W5100S/archive/1.0.2.zip + https://github.com/RAKWireless/RAK13800-W5100S/archive/1.0.3.zip # renovate: datasource=custom.pio depName=RAK NCP5623 RGB LED packageName=rakwireless/library/RAKwireless NCP5623 RGB LED library rakwireless/RAKwireless NCP5623 RGB LED library@1.0.3 # renovate: datasource=custom.pio depName=RAK12035_SoilMoisture packageName=beegee-tokyo/library/RAK12035_SoilMoisture diff --git a/variants/nrf52840/rak4631_eth_gw/platformio.ini b/variants/nrf52840/rak4631_eth_gw/platformio.ini index 033d1d9b8..eb8890e08 100644 --- a/variants/nrf52840/rak4631_eth_gw/platformio.ini +++ b/variants/nrf52840/rak4631_eth_gw/platformio.ini @@ -28,7 +28,7 @@ lib_deps = # renovate: datasource=custom.pio depName=Melopero RV3028 packageName=melopero/library/Melopero RV3028 melopero/Melopero RV3028@1.2.0 # renovate: datasource=github-tags depName=RAK13800-W5100S packageName=RAKWireless/RAK13800-W5100S - https://github.com/RAKWireless/RAK13800-W5100S/archive/1.0.2.zip + https://github.com/RAKWireless/RAK13800-W5100S/archive/1.0.3.zip # renovate: datasource=custom.pio depName=RAK NCP5623 RGB LED packageName=rakwireless/library/RAKwireless NCP5623 RGB LED library rakwireless/RAKwireless NCP5623 RGB LED library@1.0.3 # renovate: datasource=git-refs depName=RAK12034-BMX160 packageName=https://github.com/RAKWireless/RAK12034-BMX160 gitBranch=main diff --git a/variants/nrf52840/rak_wismeshtap/platformio.ini b/variants/nrf52840/rak_wismeshtap/platformio.ini index 9ccc2b796..87dd49b2c 100644 --- a/variants/nrf52840/rak_wismeshtap/platformio.ini +++ b/variants/nrf52840/rak_wismeshtap/platformio.ini @@ -31,7 +31,7 @@ lib_deps = # renovate: datasource=custom.pio depName=Melopero RV3028 packageName=melopero/library/Melopero RV3028 melopero/Melopero RV3028@1.2.0 # renovate: datasource=github-tags depName=RAK13800-W5100S packageName=RAKWireless/RAK13800-W5100S - https://github.com/RAKWireless/RAK13800-W5100S/archive/1.0.2.zip + https://github.com/RAKWireless/RAK13800-W5100S/archive/1.0.3.zip # renovate: datasource=custom.pio depName=RAK NCP5623 RGB LED packageName=rakwireless/library/RAKwireless NCP5623 RGB LED library rakwireless/RAKwireless NCP5623 RGB LED library@1.0.3 # renovate: datasource=custom.pio depName=TFT_eSPI packageName=bodmer/library/TFT_eSPI diff --git a/variants/nrf54l15/nrf54l15.ini b/variants/nrf54l15/nrf54l15.ini index 6a299c9d4..31adaee10 100644 --- a/variants/nrf54l15/nrf54l15.ini +++ b/variants/nrf54l15/nrf54l15.ini @@ -49,7 +49,7 @@ lib_compat_mode = off lib_deps = ${arduino_base.lib_deps} ${radiolib_base.lib_deps} - # renovate: datasource=git-refs depName=meshtastic/Crypto packageName=https://github.com/meshtastic/Crypto gitBranch=master + # renovate: datasource=git-refs depName=meshtastic/Crypto packageName=https://github.com/meshtastic/Crypto gitBranch=main https://github.com/meshtastic/Crypto/archive/591ff9a690e8168ccb7a36abde8d7783e448d395.zip ; Cherry-picked sensor libs from environmental_base. The full ; environmental_base pulls Adafruit_SSD1306 / GFX which need Arduino diff --git a/variants/rp2040/rak11310/platformio.ini b/variants/rp2040/rak11310/platformio.ini index 2c2b2a4bf..d9bd5cb16 100644 --- a/variants/rp2040/rak11310/platformio.ini +++ b/variants/rp2040/rak11310/platformio.ini @@ -28,6 +28,6 @@ lib_deps = # renovate: datasource=custom.pio depName=Melopero RV3028 packageName=melopero/library/Melopero RV3028 melopero/Melopero RV3028@1.2.0 # renovate: datasource=github-tags depName=RAK13800-W5100S packageName=RAKWireless/RAK13800-W5100S - https://github.com/RAKWireless/RAK13800-W5100S/archive/1.0.2.zip + https://github.com/RAKWireless/RAK13800-W5100S/archive/1.0.3.zip debug_build_flags = ${rp2040_base.build_flags}, -g debug_tool = cmsis-dap ; for e.g. Picotool diff --git a/variants/stm32/CDEBYTE_E77-MBL/platformio.ini b/variants/stm32/CDEBYTE_E77-MBL/platformio.ini index cb980db10..f0f5d7cdc 100644 --- a/variants/stm32/CDEBYTE_E77-MBL/platformio.ini +++ b/variants/stm32/CDEBYTE_E77-MBL/platformio.ini @@ -16,5 +16,8 @@ build_flags = -DMESHTASTIC_EXCLUDE_AIR_QUALITY_SENSOR=1 -DMESHTASTIC_EXCLUDE_I2C=1 -DMESHTASTIC_EXCLUDE_GPS=1 +build_unflags = + ${arduino_base.build_unflags} + -DMESHTASTIC_EXCLUDE_XEDDSA=1 -upload_port = stlink \ No newline at end of file +upload_port = stlink diff --git a/variants/stm32/rak3172/platformio.ini b/variants/stm32/rak3172/platformio.ini index 5ed5be1d7..04633ff29 100644 --- a/variants/stm32/rak3172/platformio.ini +++ b/variants/stm32/rak3172/platformio.ini @@ -15,6 +15,9 @@ build_flags = -DMESHTASTIC_EXCLUDE_AIR_QUALITY_SENSOR=1 -DMESHTASTIC_EXCLUDE_I2C=1 -DMESHTASTIC_EXCLUDE_GPS=1 +build_unflags = + ${arduino_base.build_unflags} + -DMESHTASTIC_EXCLUDE_XEDDSA=1 lib_deps = ${stm32_base.lib_deps} diff --git a/variants/stm32/stm32.ini b/variants/stm32/stm32.ini index 4300e3b7a..ea8673d77 100644 --- a/variants/stm32/stm32.ini +++ b/variants/stm32/stm32.ini @@ -25,9 +25,8 @@ build_flags = -DMESHTASTIC_EXCLUDE_BLUETOOTH=1 -DMESHTASTIC_EXCLUDE_WIFI=1 -DMESHTASTIC_EXCLUDE_TZ=1 ; Exclude TZ to save some flash space. - -DMESHTASTIC_EXCLUDE_XEDDSA=1 ; The Ed25519 signing code does not fit in the 256KB flash. Packets are sent unsigned, like pre-XEdDSA firmware. + -DMESHTASTIC_EXCLUDE_XEDDSA=1 ; Individual STM32WL variants opt in after size validation. -DMESHTASTIC_EXCLUDE_PKT_HISTORY_HASH=1 - -DMESHTASTIC_EXCLUDE_RANGETEST=1 -DMESHTASTIC_EXCLUDE_WAYPOINT=1 -DMESHTASTIC_EXCLUDE_POWER_TELEMETRY=1 -DSERIAL_RX_BUFFER_SIZE=256 ; For GPS - the default of 64 is too small. @@ -65,4 +64,4 @@ lib_ignore = OneButton ; Set a custom linker script with a higher MinStackSize value, to match NRF52. -board_build.ldscript = $PROJECT_DIR/variants/stm32/stm32wle5xx.ld \ No newline at end of file +board_build.ldscript = $PROJECT_DIR/variants/stm32/stm32wle5xx.ld