Commit Graph
2802 Commits
Author SHA1 Message Date
e78b121d9f Lr1121 tcxo optional tries xtal first, and get all my yamls in a row (#11215)
* LR11x0: try XTAL before TCXO when oscillator type is uncertain

On boards with TCXO_OPTIONAL, a TCXO-first attempt either hangs RadioLib's
calibration wait forever on a bare/non-TCXO module (unpatched upstream), or
costs a slow failed attempt before falling back even once that's fixed with
a timeout. Measured on hardware: XTAL succeeds immediately on a bare module
(~350ms) and fails fast and cleanly on a genuine TCXO module (~300ms,
RADIOLIB_ERR_SPI_CMD_FAILED), so trying XTAL first is a strict improvement
for hang-avoidance regardless of which oscillator is actually present.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* compacted

* fix review comment

* fix femtofox switches

* correct the correction

* 13

* 3s timeout

* Treat SPI_CMD_TIMEOUT as an LR11x0 init failure

The BUSY watchdog breaks RadioLib's wait, so the next bounded transfer
returns SPI_CMD_TIMEOUT rather than SPI_CMD_FAILED. Only the latter was
checked, so a watchdog-triggered failure fell through to getVersionInfo(),
setRfSwitchTable() and startReceive() against an unresponsive chip.

Also use Throttle::isWithinTimespanMs() for the watchdog's elapsed-time
check instead of raw millis() arithmetic.

* Drop the BUSY watchdog and probe XTAL before TCXO

The watchdog bounded RadioLib's unbounded BUSY wait in LR11x0::config() by
having LockingArduinoHal::digitalRead() report a stuck pin low exactly once.
That let a TCXO-first attempt fail cleanly rather than hang, but it meant
lying to RadioLib about a GPIO from a HAL shared by every radio driver.

Ordering the attempts XTAL-first avoids the hang outright instead: attempt 1
configures no DIO3 Vref, so there is no calibration wait to get stuck in, and
the TCXO fallback is only reached on a module that answered and refused XTAL.

Attempts are now XTAL, then TCXO, then a settling retry on whichever
oscillator was settled on - after a fallback that is a second TCXO attempt.
Only TCXO_OPTIONAL builds probe XTAL; a variant that declares a Vref
unconditionally still goes straight to it and never probes XTAL at all.

SPI_CMD_TIMEOUT stays a failure alongside SPI_CMD_FAILED: a bounded
per-command BUSY wait in Module::SPItransferStream() reports it in its own
right, independently of the removed watchdog.

* Drop a stray tab from the promicro TCXO readme

trunk fmt: prettier flags the whitespace-only line inside the <summary>
block, which was the only failing check on the PR.

---------

Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
Co-authored-by: Thomas Göttgens <tgoettgens@gmail.com>
2026-08-05 08:29:23 +00:00
Matias Denda 1dde97f807 Fix stack buffer overflow in aes_ccm_encr for partial blocks (#11347)
* Fix out-of-bounds write in aes_ccm_encr for partial blocks

aes_ccm_encr() writes a full 16-byte AES block to the output before XOR-ing with
the input, so a trailing partial block writes up to 15 bytes past the length the
caller asked for. Every caller in the tree passes a buffer with enough slack, so
nothing misbehaves today, but the decrypt path clears it by only a few bytes.

Encrypt into a temporary block and XOR out of it, matching what
aes_ccm_encr_auth() and aes_ccm_decr_auth() already do in this same file. The
ciphertext is unchanged.

* Add regression test for the CCM partial-block write and drop stale workarounds

The guard bytes past the caller's buffer catch the overflow without relying on a
sanitizer, so the test is meaningful in the native environment too.

encryptCurve25519() no longer needs to write extraNonce before aes_ccm_ae(): the
call stays inside numBytes now, so the copy after it is the only one required.
The comment warning about the 15-byte overshoot no longer describes the code.
2026-08-04 22:31:38 +00:00
HarukiToredaandJason P 39e7aa6a6c Full T-echo card support + Compact UI (#11342)
* T-echo card

* Update NRF52I2SOutput.cpp

* Update NRF52I2SOutput.h

* cleanup

* Update buzz.cpp

* use consistent runtime compact-panel check instead of mixing with compile-time macro

* Update NodeDB.cpp

* Update ExternalNotificationModule.cpp

* switched to Throttle::isWithinTimespanMs

* Update SharedUIDisplay.h

* trunk fix

* last cleanup

* ClockRenderer.cpp for OLED_COMPACT_UI and setup Unit C6L for new UI.

* Fixed regressions in standard OLED and TFT

---------

Co-authored-by: Jason P <applewiz@mac.com>
2026-08-04 14:27:35 +00:00
Carlos Valdes d89f3eb8fc fix(api): bound the stream drain so a full config dump can't starve the watchdog (#11164)
writeStream() drained the whole queue in one call - "send every packet we can".
A client asking for the full config gets the node database, then the file
manifest, then the packet backlog and the position replay, and none of that
returns to loop(). On a full node database (120 entries) the dump runs past
eight seconds, so on RP2350, where rp2040Loop() arms an 8s hardware watchdog
and is the only thing that calls watchdog_update(), the board resets in the
middle of the manifest. Reproducible on every connection; with a small node
database the dump finished under the timeout and nothing looked wrong.

Measured on a pico2_w5500_e22: last loop iteration at millis=27242, ServerAPI
kept logging until uptime 35s, reset at 35.2s = 27.242 + 8.0.

Take a slice instead. The PhoneAPI state machine is resumable, so writeStream()
stops after STREAM_WRITE_BUDGET_MSEC and reports whether anything is left;
runOncePart() then asks to be re-run immediately rather than sleeping out
readStream's idle delay, so the dump keeps its throughput while loop() gets to
feed the watchdog between slices. Backpressure on a retained frame still
returns the normal delay - re-running at once would just spin on a full
transport.

Verified on hardware: 10/10 full config dumps against a node with 120 entries,
no resets, dump still completes in ~8s.
2026-08-04 14:14:08 +00:00
Jonathan Bennett 7302db1672 Update LoRa firmware on Thinknode M7 (#11337) 2026-08-04 13:03:30 +00:00
4b4e82bd72 SenseCAP Indicator: RP2040 peripherals for the main firmware (#6220)
* indicator: RP2040 peripherals for the main firmware

The SenseCAP Indicator RP2040 co-processor serves as a generic
peripheral bridge over a serial protobuf link (interdevice.proto):

- FakeI2C implements TwoWire and tunnels write and read transactions,
  so the standard sensor drivers and the I2C scan work unmodified on
  the bridged second bus (WIRE1)
- FakeUART forwards GPS NMEA to the regular GPS driver
- SD card access with chunked file transfers, paged directory
  listings and card statistics; device-ui loads map tiles and map
  styles from the card behind the RP2040
- link at 2M baud with 4KB chunks, message structs kept off task
  stacks

Log messages carrying their own bracket tag render it like a thread
name. Replaces the earlier IndicatorSensor/COBS approach.

* indicator: address review

Correlate responses with request ids, serialize the shared TX buffer,
reject oversized frames, fix RX buffer overflow and NMEA truncation,
full-length file paths.

* indicator: assign the GPS FakeUART at runtime

Static initialization order across translation units is undefined,
so createGps() assigns and null-checks the bridged serial instead.
Bound the NMEA length defensively.

* indicator: bump device-ui pin to 27e6c0c

* indicator: ping/pong link probe, non-blocking runOnce, FakeI2C locking

The RP2040 sends nothing unsolicited without a GPS module attached, so
wait_ready now probes with the new ping message instead of listening
passively. runOnce skips its pump while a requester holds link_lock,
keeping the main loop from blocking for a full request timeout. FakeI2C
serializes transactions between the UI task and the main loop with an
owner-tracked lock held from beginTransmission to transaction end.

* indicator: link resync, config-honoring GPS, bridged-bus routing, stats validity

Frame resync scans to the next magic instead of flushing the RX buffer,
and the pump handles all buffered frames per pass. The RX drain reads in
bulk and the protobuf encoder gets the correct buffer bound. GPS honors
the gps_mode setting on the Indicator instead of always running. RTC,
I2C keyboard and motion sensor drivers resolve WIRE1 through
ScanI2CTwoWire::fetchI2CBus so bridged buses reach the right transport.
FakeUART implements flush/availableForWrite/const-write from the Stream
contract and fences its cross-core ring buffer. SdCardInfo.stats_valid
is passed through to device-ui, and the remote FS backend gains the
remove operation used for cleanup of failed tile saves.

* indicator: retry lost link round trips, I2CResult UNSPECIFIED

Remote FS operations retry once on a transport timeout. Correlation ids
drop late responses of the first attempt; a retried append whose first
attempt landed is recognized by the offset conflict carrying the
resulting file size. Definitive failures are not retried, missing-tile
probes stay a single round trip. Regenerated bindings add the
I2CResult.Status UNSPECIFIED zero value so an empty result cannot
decode as success.

* indicator: nack responses, rename bridge classes to I2CProxy/UARTProxy

A request the co-processor cannot decode or handle is nacked, so the
requester fails fast instead of burning its timeout. All requests stage
the shared tx_message under link_lock. FakeI2C and FakeUART are renamed
to I2CProxy and UARTProxy after the pattern they implement, with their
instances following suit. Drops dead code (unused NO_NEWS_PAUSE,
unreachable not-running branches, doubled include guards) and the GPS
pin log line that is meaningless on the tunneled port.

* indicator: refuse a co-processor that speaks another protocol version

The ping/pong handshake now carries InterdeviceVersion. A pong reporting
a version other than ours means the RP2040 runs firmware that does not
match this build, so the bridge stays shut down for the session and the
mismatch is logged with both versions. Requests fail fast instead of
being misinterpreted by the other side.

* indicator: regen protos, interdevice protocol version 2

* indicator: per-task I2C contexts, gated handshake, retryable link failures

The bridged I2C bus is shared between the main loop and the UI task, and
TwoWire has no transaction bracket a lock can span: drivers drain the read
buffer with available()/read() long after requestFrom() returned. Each
calling task therefore gets its own staging and read buffers instead of a
lock that could be left held (or that could not protect the read buffer
anyway). The transaction is staged inside the link, under its lock.

No request is sent before the co-processor has completed the version
handshake, and runOnce keeps probing until it does, so a co-processor that
boots slowly or reboots on its watchdog no longer leaves the bridge dead
for the session. Requests in flight are counted, not flagged: two threads
can be in a request and the first one out must not clear the other's state.

File operations are retried on a lost frame and on a co-processor busy with
card maintenance, but not on a refusal (nack) or a definitive failure, and
they release the SPI lock while they wait so a slow link does not starve
the radio.

* indicator: fail safe on a peer mismatch, wait out card maintenance

FileStatus moved to a fresh tag: reusing the tag of the removed success flag
made every failure status decode as success on a peer that predates it.

A card being mounted (busy) is retried rather than reported as an empty
slot, and a co-processor busy with card maintenance is waited out: mounting
takes seconds and the free space scan of a large card walks its whole FAT,
which is not a reason to report a missing tile. The bridged I2C bus releases
the SPI lock as well, so the keyboard scan on the UI task cannot starve the
radio either. Slot claims in the I2C proxy are atomic, NMEA is not sent to a
peer we refuse to talk to, and the handshake is completed by the unsolicited
ping the co-processor sends when it has booted, which also reports a
reboot.

* indicator: regen protos, FileStatus back on the original tags

* indicator: regen protos, ping/pong carry the InterdeviceVersion enum

* indicator: point the protobufs submodule at the merged interdevice protos

* indicator: pin device-ui to the branch with the remote SD support

* indicator: honor the txOnly flag of flush, report dropped GPS writes

flush() through a Stream pointer discarded the receive buffer: the flag is
txOnly, and HardwareSerial::flush() keeps what has been received. write()
reported bytes as written even when the link refused to send them. The link
probe uses Throttle for its rate limit.

* indicator: decide the log tag on the formatted message, hex request ids

The thread tag was suppressed based on the printf template, which disagrees
with the rendered message it is compared against: a format starting with a
conversion could produce two tags, and one without a trailing bracket-space
lost the tag entirely. vprintf now receives the thread name and picks. Also
shifts only the bytes actually buffered after a frame, throttles with
Throttle and logs request ids as hex.

* indicator: SD mount, eject and format commands over the link

* indicator: bound how long a busy card state blocks the UI task

* indicator: a busy co-processor must not block the UI task for ever

The busy retry re-armed its own budget on every busy answer, so a
co-processor that stayed busy kept the caller in the loop with no way out.
Transport retries and the wait for a busy card are now separate budgets that
only count down.

* indicator: start each request from an aligned receive buffer

A byte run lost mid-response (a UART overflow during a 4KB tile chunk, when
the display starves the RX interrupt) misaligns the assembly buffer. The
buffer was never reset, so the poison outlived the request and cascaded into
the following chunks of the same tile: one glitch dropped a whole multi-chunk
tile, while single-chunk tiles resynced in the idle gap and survived. Each
request now flushes the buffer first, bounding a glitch to the one chunk it
hit. Adds resync/decode/timeout counters, logged rarely, to see the rate.

* indicator: enlarge the LVGL heap for low-zoom map tiles

The heap was 3MB and the image cache reserves 1.5MB of it, so a low-zoom map
tile could not find a large enough contiguous block to decode and rendered
white. 5MB of the 8MB PSRAM fixes it with room to spare.

* indicator: advance the device-ui and protobufs pins to the merged commits

Point the protobufs submodule at the merged SD command protos (protobufs
#986) so it matches the checked in interdevice sources, and bump the
device-ui archive to the current indicator branch tip that carries the SD
button and format UI.

* Update device-ui library dependency URL

* remove cutom sdkconfig

* remove duplicated synchronisation (after PR11278 is in place)

* set commit reference to updated RemoteSDService class

* Add board_level configuration for release

* fix cppcheck errors

---------

Co-authored-by: Manuel <71137295+mverch67@users.noreply.github.com>
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
Co-authored-by: CodeRabbit <noreply@coderabbit.ai>
Co-authored-by: mverch67 <manuel.verch@gmx.de>
2026-08-01 16:23:30 +00:00
Austin 478206ea72 NodeDB: Change pointer to const for mesh node retrieval in loadFromDisk (#11315)
Addresses cppcheck
src/mesh/NodeDB.cpp:2431: [low:style] Variable 'us' can be declared as pointer to const [constVariablePointer]
2026-08-01 00:33:33 +00:00
TomandClaude Opus 4.8 e26b6bfc5a TMM & Warmstore key source naming (#11119)
* one becomes two

* warmstore clarify

* Address PR review: key-provenance terminology consistency

- Log line now says "not key-proven" (gate is XEdDSA OR manual, not just signer)
- Rename markKeySignerProvenForTest -> markKeyXeddsaSignedForTest (sets only the XEdDSA bit)
- Docs + test comments: "signer bit" -> "XEdDSA-signed bit"

clod helped too

* Rename signer-proven -> key-proven for broadened provenance predicate

Address PR #11119 review: the copyPublicKey()/copyUser() out-parameter and
the cache-path replay gate now report entry->keyProven() (XEdDSA-signed OR
manually verified), so the "signerProven" name and "signer-proven" comments
were misleading. Rename the public out-param to keyProven, the local
cachedKeySignerProven to cachedKeyProven, and update coupled callers, log
strings, docs headings, and comments to say "key-proven".

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* nitpicks

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-31 10:39:39 +00:00
597f6767b5 Add Elecrow ThinkNode M8 board support (thinknode_m8) (#11226)
* Add Elecrow ThinkNode M8 variant scaffold (thinknode_m8)

nRF52840 + SX1262 + 2.4" e-paper + ATGM336H-5NR32 GPS.
All pins resolved from ThinkNode_M8_V0.3.sch; cross-checked
against meshtastic/firmware#9181 (Elecrow V0.1 reference).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* Add Elecrow ThinkNode M8 board support (nRF52840/SX1262, 1.54in e-ink, ATGM336H GNSS, SC7A20, EC04 encoder)

* Address review: keep the stored backlight level out of blanking, match only the SC7A20 WHO_AM_I byte, and transfer detents atomically

* Use std::atomic for the press-and-turn detent counter so native builds compile

* Drop the ThinkNode M8 LED_BUILTIN redefinition that warned on every translation unit

---------

Co-authored-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-30 15:59:35 +00:00
Thomas Göttgens a5940b4c6c Guard the deferred local queue and depth counter (#11284)
* Guard the deferred local queue and depth counter

* Close the drain and enqueue race on the deferred queue

* Route the raced loopback through handleReceived

* Make the last-frame check and depth decrement atomic

* Correct the handleReceived doc comment
2026-07-30 11:13:37 +00:00
Thomas Göttgens fc67590317 Lockdown PIN redaction, a build guard, and favorite compaction (#11285)
* Redact the pairing PIN from unauthorized lockdown clients

* Fail the build when PacketAPI would bypass the lockdown gate

* Compact kept favorites when resetting the node database

* Make the compaction loop reference const
2026-07-30 11:13:11 +00:00
Benjamin Faershtein 9a37250438 fix(router): clear failed reliable send retries (#11267)
* fix(router): clear failed reliable send retries

* test(router): cover failed interface enqueue

* fix(router): retain retries after duty cycle limits
2026-07-30 11:00:32 +00:00
Thomas Göttgens 047c4e9feb add LR 2021 to portduino, and allow Framebuffer devices to rotate the screen from config (#11252)
* add LR 2021 to portduino, and allow Framebuffer devices to rotate the screen from config. Requires https://github.com/meshtastic/device-ui/pull/355 and supersedes https://github.com/meshtastic/firmware/pull/10567 and https://github.com/meshtastic/firmware/pull/11138

Many thanks to the original authors https://github.com/a-li3n and https://github.com/jessm33

* Build LR2021Interface.cpp in the wasm env

initLoRa() constructs LR2021Interface for Lora.Module: lr2021, so excluding
the file from the native-wasm source filter left the constructor undefined at
link time. LR20x0Interface.cpp stays excluded; it is template-only and comes in
via the InterfacesTemplates.cpp amalgamation.

Also replace the non-UTF-8 degree signs in the framebuffer rotation comment and
correct the rfswitch alias cleanup comment.

* Trim comments

* Report setenv failure for the framebuffer rotation
2026-07-30 10:44:52 +00:00
Thomas Göttgens 9c260ad792 Signature key resolution and a send-path leak (#11283)
* Verify signatures against authoritative keys only

* Release the packet on the unset-variant send error
2026-07-29 22:30:30 +00:00
Thomas Göttgens daf1213580 Licensed channel defaults, phone map growth, and payload read bounds (#11286)
* Strip the default PSK when licensed defaults are installed

* Only record rate-limited portnums from the phone

* Bound payload reads by the received size
2026-07-29 22:29:12 +00:00
Thomas Göttgens df6e67f70b Signing and ingress hardening (#11282)
* Include warm-tier signers in the identity update gate

* Fail the send when PKI encryption fails

* Require signatures on licensed unicasts

* Include warm-tier signers in the NodeInfo downgrade drop

* Clamp hop fields on UDP multicast ingress

* Address review comments on signing hardening

Condense the updateUser rationale to two lines and stop calling the
Balanced-mode drop a broadcast now that licensed unicasts reach it.
2026-07-29 22:28:25 +00:00
Tom 2024bb8384 Arrival time fix perhaps (#11274)
* Add explicit presence for MeshPacket.rx_time (arrival time)

rx_time is now proto3 optional with a has_rx_time presence bit, matching
the rx_rssi treatment. A node with no GPS and no phone connected yet has
no time source at all, so a bare 0 was indistinguishable from a genuine
1970-01-01 reading; downstream consumers (replay packets, JSON
serialization) now check has_rx_time instead of the value.

* Dedupe rx_time stamping into a shared helper; trim a debug log string

Extract the repeated haveTime/rx_time/has_rx_time stamp logic (5 call
sites across Router.cpp, MeshBeaconModule.cpp, MeshService.cpp) into
Router::computeRxTimeStamp()/stampRxTime(). Also shorten the new RTC.cpp
LOG_DEBUG string. Saves 48 bytes of flash on rak4631 (measured), no
behavior change.

* Fix has_rx_rssi presence carried unconditionally through StoreForward replay

preparePayload() set has_rx_rssi = true unconditionally on replay, regardless
of whether the packet's rx_rssi at store time was a genuine measurement (e.g.
MQTT-relayed packets carry no real RSSI). Store the presence bit alongside
rx_rssi in PacketHistoryStruct and restore it on replay instead.

Flagged by Copilot on #11271 (same root cause the has_rx_time explicit
presence work fixes) but never addressed before that PR merged.

* Trim comment blocks to the repo's 1-2 line guideline

.github/copilot-instructions.md:338 caps code comments at 1-2 lines; several
blocks added across the rx_time explicit-presence work ran well past that.
Also consolidates Time.cpp's file-level doc comment into Time.h, where the
rest of the Time:: API contract already lives.

No behavior change.

* Add rx_time explicit-presence test coverage

- test_meshpacket_serializer: has_rx_time=false fixture plus tests asserting
  JsonSerialize/JsonSerializeEncrypted emit 0 rather than leaking the
  millis() placeholder, alongside the has_rx_time=true baseline.
- test_stream_api: two tests driving a real PhoneAPI handshake (want_config_id
  through STATE_SEND_PACKETS) that simulate a phone time-giving transaction
  arriving before vs. after a queued packet is drained - covering both the
  reconciled and the ships-with-placeholder-absent paths of
  MeshService::reconcilePendingRxTimes().

* Fix three correctness issues flagged in review

- Time.h: drop the reserved-identifier include guard (_MT_TIME_H); pragma
  once already covers it, matching convention elsewhere (e.g. RTC.h).
- Time.cpp: rebase getMillis64()'s wrap accumulator when the test seam
  swaps clock sources, so a real<->injected clock jump isn't miscounted
  as a genuine 32-bit wrap.
- NodeInfoModule: the 12h reply-suppression window is a local dedup
  duration, not a wall-clock reading - switch it to Time::getMillis64()
  so RTC-quality jumps and replayed packets' stale rx_time can't perturb
  it.
- StoreForwardModule: has_rx_time was derived from *current* RTC quality
  at replay time rather than stored at capture time, so a history entry
  saved while time-blind could be misreported as a valid epoch once the
  clock later improved. Persist the presence bit in PacketHistoryStruct
  instead.

* tryfix CI

* post review fixes

* more test fixes
2026-07-29 14:03:25 +00:00
Benjamin Faershtein 0fef83d434 Add configurable event mode hop limit (#11275)
* feat: resolve event mode hop limit

* feat: bake event mode hop limit

* fix: honor event mode hop cap in routing

* docs: expose event mode hop limit preference

* fix: enforce event hop defaults across routing

* docs: clarify event hop override behavior

* refactor: simplify event mode hop preference

* fix: cap equal event hop limit
2026-07-29 10:54:48 +00:00
Tom 2c57a17124 Phantom node fix perhaps (#11271)
* trying to fix phantom nodes

* fix comment spam

* oops - missed one
2026-07-28 18:32:18 +00:00
Thomas Göttgens cdd996248c Release the opaque relay copy the interface declines to send (#11262)
relayOpaquePacket() allocates a copy and returns Router::send(relay) == ERRNO_OK, discarding ERRNO_SHOULD_RELEASE. The interface returns that for NODENUM_BROADCAST_NO_LORA, so the copy is never freed and one pool slot leaks per frame.

The opaque path is reached for packets on a channel we have no key for, so no key or PSK is needed: a frame with an unknown channel hash, to=NODENUM_BROADCAST_NO_LORA, a nonzero id and hop_limit>0 leaks a slot, and roughly MAX_PACKETS of them exhaust the pool until reboot.

#11087 fixed this pattern in perhapsRebroadcast and the retransmission paths but did not cover relayOpaquePacket, which was added separately with the opaque relay path.
2026-07-27 15:57:39 -04:00
Thomas Göttgens 80f972655d Null-check reply allocations in allocErrorResponse and RemoteHardware (#11260)
allocAckNak returns nullptr when the packet pool is exhausted, but allocErrorResponse passed the result straight to setReplyTo, which dereferences it. Reachable unauthenticated: an ADMIN_APP packet on a known-key channel takes the NOT_AUTHORIZED path, so a flood that empties the pool crashes the node. MemoryDynamic::alloc no longer aborting on failure made this reachable on PSRAM, STM32WL and Portduino targets. Callers already treat a null reply as no response. Same fix for the two RemoteHardwareModule sites.
2026-07-27 14:54:57 -04:00
a640d44b5c Add T-Beam BPF - 144-148 Mhz LoRa @ ~37 dBm (5 Watts) (#10558)
* Add T-Beam BPF (144-148 Mhz LoRa)

* minor correction to fix compiler warnings

* Add T-Beam BPF (144-148 Mhz LoRa)

* minor correction to fix compiler warnings

* Add ITU regions for this device and make GPS work.

* Switch pin after defining it as output

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>

* Lora CS is indeed 1, SD Card CS is 10

* Include the back option.

* Fix compilation with pioarduino (USB_MODE)

* Default ham to narrow_fast

* Default PROFILE_HAM to slot 17

This is an appropriate default in the USA but not the EU.

The slot override really should follow the region itself, not the regionprofile.

* Fix for ITU 2/3 split

* Add ITU region options to MenuAction enum

* Add HAS_HAM_2M definition to variant headers for 2M support

* Re-add PROFILE_HAM regionprofile

Accidentally removed in last merge

* Trunk fmt

* Initial default slots

* Switch to TinyFast

Still need to flesh out the default channels

* Adjust slotOverrides for TinyFast

* RadioLib doesn't accept 15.625 kHz

Use 15.6 instead

* Set RF95 pins for T-Beam Supreme

May cause regressions!!

* Remove other-variant changes (BPF-only)

These have been moved to other PRs

* Remove mismatch guarding (we need a more comprehensive approach)

* Add comment back

* Add template for PA curve

* This is a 5-6W radio!! Add TX_GAIN_LORA

* Trunk fmt because NomDeTom hates emdashes

---------

Co-authored-by: Thomas Göttgens <tgoettgens@gmail.com>
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-07-27 13:54:53 +00:00
1e982fa78c Sign plaintext packets in licensed mode (#10969)
* feat: sign licensed plaintext packets

Preserve and publish identity keys in licensed mode so existing XEdDSA signatures can authenticate plaintext traffic. Sign licensed broadcasts and direct messages when they fit, while keeping PKI encryption disabled and normal routing behavior unchanged.

* fix(security): persist licensed channel sanitation

* fix(security): close licensed migration lifecycle gaps

* fix(security): address licensed signing review feedback

* test: restore signing globals from Unity teardown

* fix(baseui): allow confirmed ham region selection

* fix(baseui): guard region picker validation

* style: trunk fmt

---------

Co-authored-by: Ben Meadors <benmmeadors@gmail.com>
Co-authored-by: Austin <vidplace7@gmail.com>
2026-07-27 02:57:15 +00:00
45cb8e7500 feat: preserve normal radio profiles across event firmware (#11110)
* feat: isolate event radio profiles

* fix: preserve identity on degraded config boot

* fix: guard event profile storage

* style: align event profile storage names

* fix: discard event profile on normal boot

* refactor: simplify event profile cleanup

* fix: limit inactive profile migration to event firmware

* fix(event): make event-profile capacity check portable across filesystems

The USERPREFS_EVENT_MODE storage preflight called FSCom.totalBytes() and
FSCom.usedBytes(), which only exist on ESP32's LittleFS wrapper. Every other
backend failed to compile once event mode was enabled:

  error: 'class InternalFileSystem' has no member named 'totalBytes'   (nRF52)
  error: no member named 'totalBytes' in 'fs::FS'                      (Portduino)

This was not caught earlier because the event block is behind
#if USERPREFS_EVENT_MODE, and the existing unit tests only cover the pure
helpers, which compile identically either way.

Add fsTotalBytes()/fsUsedBytes() to FSCommon and implement them per backend:
littlefs v1 traversal for nRF52 (Adafruit_LittleFS) and STM32
(STM32_LittleFS), FSInfo for RP2040, statvfs for Portduino, and the native
methods for ESP32 and nRF54L15. The nRF52/STM32 path reports "full" if the
traversal errors so the capacity check fails safe.

Verified with USERPREFS_EVENT_MODE=1: native-macos test_event_profile_storage
passes 5/5, and heltec-v3, rak4631, rak11310 and rak3172 all build clean.

* fix(event): use std::filesystem for Portduino capacity, bump native-suite-count

Two CI fixes:

- native-windows has no <sys/statvfs.h>, so the Portduino branch of
  fsTotalBytes()/fsUsedBytes() broke the Windows build. Switch to
  std::filesystem::space(), which is already used under ARCH_PORTDUINO in
  HostMetrics.cpp and works on both POSIX and MinGW. Errors report "full"
  so the capacity check still fails safe.

- This PR adds test/test_event_profile_storage, taking test/ from 40 to 41
  suite directories, which trips the native-suite-count reconciliation check.

* fix(event): scope boot-write deferral to the radio profile, address review

Review feedback:

- Copilot: saveProto()'s boot-write deferral applied to every file, so
  loadFromDisk()'s recovery writes (e.g. restoring owner fields into
  devicestate) were silently dropped and never retried, because the ctor CRC
  baselines are computed after loadFromDisk(). Deferral now applies only to
  the radio-profile files, via isRadioProfileFile().

- jp-bennett: eventConfigFromStandard() wrapped a struct copy plus one field
  assignment in a header helper with its own unit test. Inlined at its single
  call site and removed; the behaviour is covered end-to-end by hardware
  validation instead (RAK4631 normal -> event -> normal preserves NodeNum and
  public key while swapping LoRa, and restores the original channel
  byte-identically).

- jp-bennett: the active-backup encrypted-storage migration ran for normal
  builds too, which changed non-event behaviour and contradicted the PR's
  stated event-only scope. Scoped to USERPREFS_EVENT_MODE; the adjacent block
  already covers the inactive standard backup in event builds.

New tests (all verified to fail under mutation, not vacuous):

- test_event_paths_never_collide_with_standard_or_shared_files: the core
  safety property. A path-table slip would make event firmware overwrite the
  user's real config, channels or backup, or capture a shared file like
  devicestate/nodedb. Nothing asserted this before.

- test_only_radio_profile_files_defer_boot_writes: guards the deferral fix
  above so re-widening it fails loudly.

- test_event_reservation_fits_smallest_supported_filesystem: the reservation
  is compile-time but must fit filesystems as small as 14 KiB (STM32WL) and
  28 KiB (nRF52840). Protobuf growth pushing it past those would silently
  stop event profiles persisting - the exact failure mode confirmed by
  fault injection on a RAK4631.

Verified with USERPREFS_EVENT_MODE both on and off: 7/7 tests pass, and
rak4631, heltec-v3, rak11310 and rak3172 all build clean.

* fix: preserve event profile storage recovery

---------

Co-authored-by: Jonathan Bennett <jbennett@incomsystems.biz>
Co-authored-by: Ben Meadors <benmmeadors@gmail.com>
Co-authored-by: Benjamin Faershtein <benjaminfaershtein@Benjamins-MacBook-Pro-2.local>
2026-07-26 22:58:23 +00:00
James RichandClaude Fable 5 8e104a909b fix(admin): persist TAK module config (team color / member role) (#11216)
Setting TAK team/role ACKed and rebooted but stored nothing:
handleSetModuleConfig had no tak case, saveToDisk never set has_tak,
and handleGetModuleConfig had no TAK_CONFIG case. Add all three, plus
a native suite sweeping every ModuleConfig submessage through
set -> save -> load -> get and a TAK value-fidelity suite.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-25 23:03:06 +00:00
b9a5443480 Fix multicast address (#8612)
Co-authored-by: Ben Meadors <benmmeadors@gmail.com>
Co-authored-by: Jason P <applewiz@mac.com>
2026-07-25 11:54:39 +00:00
Andrew YongandBen Meadors abf217c38d fix(mesh): don't assert on malloc() failure in MemoryDynamic::alloc() (#11197)
* fix(mesh): don't assert on malloc() failure in MemoryDynamic::alloc()

MemoryDynamic<T>::alloc() called assert(p) right after malloc(), instead
of returning nullptr like the static MemoryPool<T,N>::alloc() already
does on exhaustion. All of packetPool's callers were already hardened to
null-check allocCopy()/allocZeroed() (#10948, #10951), but that path is
unreachable on real OOM here: assert() fires first, one level down,
before the caller ever gets a chance to check anything.

On STM32WL (packetPool is MemoryDynamic there - not enough static RAM
for the fixed pool), assert() failures are wrapped to an infinite
`while(true);` loop rather than aborting or resetting, so the thread
just hangs forever instead of returning nullptr. Reproduced on wio-e5
hardware under a burst of incoming DMs, with free heap draining toward
zero right before the hang.

Assisted-by: Claude Sonnet 5 <noreply@anthropic.com>
Signed-off-by: Andrew Yong <me@ndoo.sg>

* fix: null-check the two allocUnique*() call sites missed by prior audits

#10948/#10951 null-checked every raw-pointer packetPool.allocCopy()/
allocZeroed() call site, but missed the two spots using the
UniquePacketPoolPacket (unique_ptr) wrapper: UdpMulticastHandler::onReceive()
and MQTT::onReceive() both dereferenced the allocation result unconditionally.

Previously unreachable in practice: MemoryDynamic::alloc() asserted before
ever returning null, so nothing downstream saw it. Fixed alongside that
assert removal (this branch) since these two are now reachable with a
genuine null - same fix as everywhere else, just an unchecked pointer that
turns up under OOM.

Assisted-by: Claude Sonnet 5 <noreply@anthropic.com>
Signed-off-by: Andrew Yong <me@ndoo.sg>

* fix(SimRadio): don't leave isReceiving stuck true on allocation failure

startReceive() set isReceiving = true before allocCopy(), so a failed
allocation (now reachable with a real nullptr instead of hanging in
assert()) left the simulated radio permanently marked as receiving:
handleReceiveInterrupt() returns immediately on a null receivingPacket,
before ever reaching the code that would clear isReceiving.

Assisted-by: Claude Sonnet 5 <noreply@anthropic.com>
Signed-off-by: Andrew Yong <me@ndoo.sg>

---------

Signed-off-by: Andrew Yong <me@ndoo.sg>
Co-authored-by: Ben Meadors <benmmeadors@gmail.com>
2026-07-25 11:28:42 +00:00
Thomas Göttgens b46c8c9f80 Router: defer nested local sends to avoid handleReceived re-entrancy (#11191)
Prevents an nRF52 task-stack overflow on config save. Replaces draft #11155.
2026-07-24 19:30:48 +00:00
Ben Meadors 01d1873bfc Deliver locally-generated replies addressed to us to the phone (#11185)
* Deliver locally-generated replies addressed to us to the phone

Config get/set from the phone times out on every device: the client sends an
admin request, the node handles it, and the response is silently dropped
before it reaches the phone queue.

#10967 changed Router::sendLocal's isToUs branch from enqueueReceivedMessage()
to handleReceived(p, src), so a local packet keeps its RxSource instead of
being relabeled RX_SRC_RADIO by the queue round-trip. That is the right call
for the new policy gates, but module replies go out through
MeshService::sendToMesh() with the default RX_SRC_LOCAL, and a reply to a
phone-originated request is addressed to our own node (setReplyTo resolves
from == 0 to ourNodeNum). Those replies now re-enter callModules as
RX_SRC_LOCAL, where the loopback gate skips every module whose loopbackOk is
false - including RoutingModule, whose promiscuous sniff is the only path that
moves a received packet into toPhoneQueue. The reply is released, never sent.

Requests still work, because the phone's own packets arrive as RX_SRC_USER and
pass the gate, so a set_config is applied and only its acknowledgement is lost.
That is why a client can connect and download config but times out on every
config screen and every setter.

Deliver the phone's copy from sendToMesh() instead: for a local packet
addressed to us, the loopback gate is doing its job in keeping the packet away
from module re-dispatch, and the phone copy is exactly what is missing. Setting
loopbackOk on RoutingModule would instead echo every locally-generated
broadcast back to the phone, and relabeling replies RX_SRC_RADIO would undo the
origin separation #10967 added.

Also stop reporting ERRNO_SHOULD_RELEASE (35) to the phone in the QueueStatus
for these packets. It means "caller frees", not a send failure, and the same
hunk changed it from the 0 the phone used to see.

* Address review: trim comments, assert the QueueStatus count

Condense the added comments to the one-or-two-line house style; the rationale
lives in the commit message and PR.

The reply test drained QueueStatus records in a while loop, which would have
passed just as happily on an empty queue. Count them and require both the
request's and the reply's.
2026-07-24 09:02:51 +00:00
Jason PandBen Meadors ef1aedd569 Fixes a crash when you favorite a node (#11146)
Co-authored-by: Ben Meadors <benmmeadors@gmail.com>
2026-07-23 10:08:34 +02:00
Ben MeadorsandAustin efc9708236 Add userpref for the packet signature policy default (#11144)
Co-authored-by: Austin <vidplace7@gmail.com>
2026-07-23 10:05:02 +02:00
nomdetom 583248c19d claw back some space from signing policies 2026-07-22 14:36:06 +01:00
Thomas Göttgens f09f2b1366 Gate the PKI-decrypt key on signer-proven, not the opportunistic cache (#11116) 2026-07-22 11:12:06 +02:00
Benjamin Faershtein 91915a0539 Merge commit '5548bd3195c18ec9274b2ff8171504acfc2647b3' into agent/include-statusmessage-config 2026-07-21 10:11:16 -07:00
Benjamin Faershtein 9cce99400b phoneapi: send status message config 2026-07-21 10:10:34 -07:00
Ben Meadors e11635baa2 Merge branch 'develop' into codex/packet-auth-policy 2026-07-21 07:18:02 -05:00
Thomas Göttgens d587f08481 Pin admin responses to the stored peer key and request id (#11092)
* Pin admin responses to the stored peer key and request id

noteOutgoingAdminRequest derived its PKC pin from p.public_key, which nothing
populates on the outgoing path, so keyValid was false for every client request
and the pin never engaged. The accepted-response predicate reduced to an
unauthenticated from plus variant and subtype.

Resolve the destination key from NodeDB the way perhapsEncode does, and pin it
only when the request would actually be PKC-encrypted. Extract that condition
from perhapsEncode as wouldEncryptWithPKC so both use one predicate. Also
record the request's packet id and require the response to echo it.

* Treat a zero request id as no pairing token
2026-07-21 07:16:22 -05:00
Ben Meadors 7d954e668d Merge branch 'develop' into codex/packet-auth-policy
Resolve conflicts against the NodeDB signer/key primitives (#11050) and the
admin-key PKI decrypt budget (#11100).

- NodeDB: drop this branch's hasSeenXeddsaSigner in favour of develop's
  isKnownXeddsaSigner. They answer the same question, but develop's reads the
  dedicated warm signer bit (warmSignerOf) rather than the WarmProtected
  category, and TrafficManagementModule already depends on it. Keep develop's
  copyPublicKey/copyPublicKeyAuthoritative, isVerifiedSignerForKey and
  commitRemoteKey/KeyCommitTrust.
- checkXeddsaReceivePolicy: keep this branch's Strict/Balanced/Compatible
  policy, which is a superset of develop's balanced-only downgrade gate, and
  call isKnownXeddsaSigner from it. develop's !pki_encrypted term is dropped
  because the policy returns early for PKI packets before that check.
- perhapsDecode: keep develop's key resolution (NodeDB then pending-key, only
  for real PKI candidates) plus its admin-key token bucket, and re-apply this
  branch's pkiAttempted flag feeding the DECODE_OPAQUE verdict. Keep both
  passesRoutingAuthGate and adminKeyFallbackAllowed/Refund.
- test_A17: model eviction the way NodeDB actually does it, passing the warm
  signer bit as well as the XeddsaSigner category, since isKnownXeddsaSigner
  reads the former.

Native suite: 38 suites, 743/743 cases, no sanitizer findings.
2026-07-21 06:09:57 -05:00
1872e5b306 TMM extend to ephemeral 3rd tier store (#11050)
* Harden TMM NodeInfo direct-response: staleness, key hygiene, throttle

The NodeInfo direct-response path answered queries on behalf of other nodes
from an unauthenticated, never-expiring cache while suppressing the genuine
request (STOP). That let a long-gone or forged identity be served as a
fresh-looking, authoritative reply indefinitely. Three fixes:

- Staleness gate: refuse to spoof a reply for a node not actually heard within
  ~6h (PSRAM cache via lastObservedMs; NodeDB fallback via sinceLastSeen, which
  tolerates last_heard==0 when the clock is unset). A stale entry now falls
  through so the real request propagates instead of being answered for a dead
  node.

- Key hygiene: mirror NodeDB::updateUser() PKI checks when caching overheard
  NodeInfo - reject a packet advertising our own public key, and pin keys (drop
  a NodeInfo whose key mismatches an already-known key from NodeDB or from our
  own cache). Prevents cache poisoning / key substitution via the spoofed-reply
  path.

- Response throttle: at most one spoofed reply per target node per 30s. Direct
  responses bypass the per-sender rate limiter (they STOP the request first) and
  the reply target is attacker-controlled, so this bounds airtime exhaustion and
  reflected floods. Recorded in a new NodeInfoPayloadEntry.lastResponseMs
  (PSRAM; self-expiring timestamp compare, no sweep needed).

Tests: native NodeDB-fallback staleness (stale drops, fresh serves) plus
PSRAM-guarded staleness, throttle, and key-mismatch cases.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01G7QggdwMM1CJkguuCmMfZs

* Add TODO(T1-T9) markers for code-review findings on NodeInfo hardening

Comment-only. Tags each site flagged in the review of the direct-response
throttle/staleness/key-hygiene change so the follow-ups are visible in-context:

T1 throttle black-holes distinct requestors; T2 per-target key doesn't bound
aggregate TX; T3 non-PSRAM fallback unthrottled; T4 millis-wrap defeats the
staleness gate; T5 redundant second O(n) scan for the throttle stamp;
T6 duplicated 32-byte key compares; T7 sinceLastSeen() called twice;
T8 two 6h constants can desync; T9 clockMs()==0 collides with the 0 sentinel.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01G7QggdwMM1CJkguuCmMfZs

* Address code-review cleanups T6, T7, T8 in NodeInfo direct-response

T8: derive kNodeInfoMaxServeAgeSecs from kNodeInfoMaxServeAgeMs so the PSRAM
    and NodeDB-fallback staleness windows cannot desync.
T6: collapse the three duplicated 32-byte key compares in cacheNodeInfoPacket
    (owner-impersonation + NodeDB pin + cache TOFU pin) into one pubKeysEqual()
    helper, so the compare lives in one place.
T7: compute sinceLastSeen(node) once on the fallback staleness path so the
    tested age and the logged age cannot diverge (it calls getTime() each time).

Remaining review items still marked in-code: T1/T2/T3 (throttle design),
T4/T9 (millis-wrap + 0-sentinel), T5 (redundant throttle-stamp scan).

Native test_traffic_management suite: 48/48 passing.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01G7QggdwMM1CJkguuCmMfZs

* Throttle the NodeDB fallback direct-response path (T3)

The per-target NodeInfo response throttle lived only in the PSRAM
NodeInfoPayloadEntry, so non-PSRAM boards - which answer from the NodeDB
fallback path - emitted spoofed direct replies with no rate limit at all,
leaving the airtime/reflection surface the throttle exists to close.

Add a single module-global fallback stamp (nodeInfoFallbackLastResponseMs,
4 bytes, guarded by cacheLock) that throttles the fallback path to one
spoofed reply per window across all targets. Coarser than the PSRAM
per-target throttle, but the fallback path has no per-node slot to stamp,
and a global cap still bounds spoofed transmissions - which is the point.

Add a native test exercising the fallback throttle window.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01G7QggdwMM1CJkguuCmMfZs

* Make NodeInfo staleness/throttle wrap- and sentinel-safe (T4, T9)

T9: the millis-based fields that use 0 as a "never" sentinel
(lastObservedMs, lastResponseMs, nodeInfoFallbackLastResponseMs) could be
stamped with a literal 0 during the one-millisecond clockMs()==0 instant at
each ~49.7-day wrap, momentarily colliding with the sentinel and disabling
the staleness/throttle gate for that entry. Route all such stamps through a
new nowStampMs() that maps 0 -> 1; the 1 ms skew is irrelevant to every
window these fields feed.

T4: the staleness gate's modular age compare is only unambiguous while true
age < 2^32 ms (~49.7 days); an entry that lingered unrefreshed that long
could wrap to a small age and read as fresh, defeating the gate. Add a
NodeInfo eviction pass to the maintenance sweep that drops entries past the
serve window, so an entry is removed long before its age can approach the
wrap boundary. This also frees slots holding stale identities.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01G7QggdwMM1CJkguuCmMfZs

* Stamp PSRAM throttle entry by captured index, not a rescan (T5)

The post-send throttle stamp did a second full O(n) scan of the PSRAM
NodeInfo array under a fresh lock, even though findNodeInfoEntry() had
already located the slot at the top of shouldRespondToNodeInfo(). Capture
the slot index during that initial lookup and address the entry directly on
the stamp path. Because the cache lock is released between the two accesses,
the slot could have been evicted or reused, so re-validate node == p->to
under the lock before stamping.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01G7QggdwMM1CJkguuCmMfZs

* Document accepted per-target throttle tradeoffs (T1, T2)

Convert the T1/T2 TODO markers into a NOTE: the per-target PSRAM throttle
keying and its lack of an aggregate bound are deliberate design choices, not
pending work. A distinct requestor being throttled for one window is
harmless on a redundant mesh, and keeping the PSRAM path per-target
preserves throughput for legitimate multi-target responders (the fallback
path already bounds aggregate via its global stamp).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01G7QggdwMM1CJkguuCmMfZs

* Track signed-provenance of cached NodeInfo public keys

Add keySignerProven to NodeInfoPayloadEntry, set when an observed NODEINFO
frame's XEdDSA signature was verified (mp.xeddsa_signed). This distinguishes
a trust-on-first-use key from one proven to belong to a signer. The flag is
monotonic - once proven it stays proven, and the existing key-pin checks
forbid the underlying key from changing - so a later unsigned frame cannot
downgrade it. A signature can only be verified against a key we already
held, so a first-contact key is always TOFU until a later signed frame
upgrades it.

Groundwork for using the TMM cache as a last-resort public-key source, where
this flag serves as a trust tier.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01G7QggdwMM1CJkguuCmMfZs

* Draw public keys from the TMM NodeInfo cache as a last resort

Add TrafficManagementModule::copyPublicKey() and consult it from
NodeDB::copyPublicKey() after the hot (NodeInfoLite) and warm
(WarmNodeStore) tiers miss. This extends the pool of peers the node can
encrypt to: a key the NodeInfo direct-response cache overheard for a node
that has since aged out of both NodeDB tiers can still be used.

The getter reports whether the key is signer-proven or trust-on-first-use.
NodeDB serves TOFU keys here too - the same first-contact trust NodeDB
already applies in updateUser() - so the pool actually expands to new
long-tail nodes rather than only re-confirming known keys.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01G7QggdwMM1CJkguuCmMfZs

* Retain keyed NodeInfo entries as a pubkey pool; trust-tier eviction

Now that NodeDB::copyPublicKey() draws keys from the NodeInfo cache, the flat
6 h serve-window eviction would discard useful keys the moment a node stops
being servable. Split retention: an entry carrying a 32-byte public key is
kept for kNodeInfoKeyRetentionMs (7 d), while a keyless entry still expires
at the serve window. Both windows stay well under the ~49.7-day millis wrap,
preserving the T4 wrap-safety guarantee.

Make LRU victim selection trust-tiered to match: a keyless slot is sacrificed
before any keyed one, and a trust-on-first-use key before a signer-proven
key; within a tier the oldest loses. Mirrors WarmNodeStore's keyed-first
admission so the most valuable keys are the stickiest under memory pressure.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01G7QggdwMM1CJkguuCmMfZs

* Test signed-provenance flag and copyPublicKey key-pool source

PSRAM-gated tests (run on the ESP32 build, alongside the existing NodeInfo
PSRAM tests):
- copyPublicKey serves a TOFU key learned from an unsigned NodeInfo and
  reports signerProven=false
- a later signature-verified NodeInfo upgrades provenance to signer-proven
  while the pinned key bytes stay unchanged
- copyPublicKey reports a miss for an uncached node

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01G7QggdwMM1CJkguuCmMfZs

* Inherit signer provenance from NodeDB when caching a re-found node

When the TrafficManagement NodeInfo cache re-caches a node, mark its key
signer-proven if NodeDB already knows the node as a verified signer for that
same key - even if this particular (unicast/unsigned) frame carried no
signature. Previously the flag only upgraded on a frame we verified
ourselves, so a node we had already proven elsewhere looked TOFU here.

Add NodeDB::isVerifiedSignerForKey(), which checks both tiers - the hot
store's signed bitfield and the warm tier's cached signer bit - and requires
the key to match so a rotated/mismatched key never inherits a stale verdict.
Add WarmNodeStore::isVerifiedSigner() to expose the warm signer bit (the
rebase onto develop added the bit itself; this surfaces it for lookups).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01G7QggdwMM1CJkguuCmMfZs

* Lock NodeInfoPayloadEntry packing with a size assert

keySignerProven cost zero bytes: sourceChannel, the two bools, and
decodedBitfield are four 1-byte fields that fill a single 4-byte tail word
(struct alignment is 4), so the flag consumed former padding rather than
growing the 2000-entry PSRAM array. Add a static_assert pinning the entry to
sizeof(meshtastic_User) + 20 so a future 5th trailing byte - which would open
a fresh word (~8 KB PSRAM across the array) - fails the build instead of
silently costing memory, prompting new flags to be packed into existing bytes.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01G7QggdwMM1CJkguuCmMfZs

* Pack NodeInfo cache booleans into 1-bit fields

Fold hasDecodedBitfield and keySignerProven into adjacent uint8_t:1 bitfields
so they share a single byte, reserving 6 spare bits for future flags without
growing the 2000-entry PSRAM array. sizeof is unchanged (still one packed tail
word, sizeof(meshtastic_User) + 20); access is by name exactly as before, so no
call sites change. Reorders decodedBitfield ahead of the flags so the two
1-bit members stay adjacent and the compiler packs them together.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01G7QggdwMM1CJkguuCmMfZs

* Gate NodeInfo replay on signer-proven provenance (compile-time, default on)

Add TMM_NODEINFO_REPLAY_REQUIRE_SIGNED (default 1): the direct-response path
now only spoofs a reply for a node whose key is signer-proven, in addition to
the staleness gate - so replay is based on flagging AND staleness. Replay is a
courtesy feature, and vouching for an unverified (trust-on-first-use) identity
to other nodes is the risk this closes, so signed-only is the safer default.
Define the macro to 0 at build time to also serve fresh TOFU-only nodes.

Both paths are gated: the PSRAM path checks the cached keySignerProven flag,
the NodeDB fallback checks nodeInfoLiteHasXeddsaSigned(node). The effective
gate (TMM_NODEINFO_REPLAY_SIGNED_GATE) is bypassed when PKI is excluded from
the build, since nothing can be signed there and the courtesy feature would
otherwise be disabled outright.

Tests: existing reply-expecting tests establish signer-proven state (a new
markKeySignerProvenForTest hook for the PSRAM cache, the NodeDB signed bit for
the fallback path); add fallback and PSRAM tests asserting an unsigned/TOFU
node is withheld under the default gate.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01G7QggdwMM1CJkguuCmMfZs

* Rehydrate re-admitted node names from the TMM NodeInfo cache

The warm tier keeps an evicted node's key but not its name (the 40-byte
record has no room), so a re-admitted long-tail node is nameless until its
next NodeInfo broadcast. The TrafficManagement NodeInfo cache is much larger
(2000 PSRAM entries) and commonly still holds the full User, so use it as a
name reservoir the warm tier structurally cannot be.

On re-admission (getOrCreateMeshNode), after the warm-tier restore, copy the
cached identity from the TMM cache via a new copyUser() getter - but only when
its cached key matches the key just restored from warm, so a name never
attaches to a different identity than the one we encrypt to (key-matched
trust). Guarded by HAS_TRAFFIC_MANAGEMENT and a null check; a no-op without
the PSRAM cache or when no key is present. CopyUserToNodeInfoLite sets only
user-related bits, so the warm-restored signer bit is preserved.

Limitations (by design): PSRAM-only, and the TMM cache is RAM-only, so this
helps within an uptime session, not across reboot.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01G7QggdwMM1CJkguuCmMfZs

* Drop non-portable NodeInfoPayloadEntry size assert (fixes pico build)

The static_assert pinned sizeof(NodeInfoPayloadEntry) to
sizeof(meshtastic_User) + 20, but nanopb packs the generated User struct
differently per platform: on pico its size is not a multiple of 4, so
alignment padding before the uint32 timestamps makes the overhead 22, not
20, and the assert failed the build (native happened to be +20 and passed,
hiding it). The bitfield packing it was guarding still stands; replace the
fixed-count assert with a comment, since no portable byte count exists.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01G7QggdwMM1CJkguuCmMfZs

* Run the NodeInfo cache paths in native tests (TMM_HAS_NODEINFO_CACHE)

The NodeInfo direct-response cache and everything layered on it - key
pinning, signer provenance, staleness, throttle - was compiled only for
ARCH_ESP32 + BOARD_HAS_PSRAM, so its entire test set was skipped by the
native CI suite and ran nowhere but real hardware.

Fold the scattered platform guards into one TMM_HAS_NODEINFO_CACHE macro
that also enables the cache (plain heap, delete[] path already existed)
for ARCH_PORTDUINO unit-test builds. Production portduino and embedded
test builds are unchanged. Tests that exercise the NodeDB fallback path
drop the cache explicitly via a new dropNodeInfoCacheForTest() hook, so
both response paths are covered in one binary.

Native suite: 60/60 (was 50 with the 10 cache tests compiled out).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KWHDDxY4U2XLYW2WJJ2oLT
(cherry picked from commit b2dafbbbd6501b42fc27175e599fb38be292b0e3)

* Pin NodeInfo cache keys against the warm tier, not just the hot store

cacheNodeInfoPacket() mirrored NodeDB::updateUser()'s key pin but checked
only getMeshNode() - the hot store. updateUser's own pin effectively
covers the warm tier too (getOrCreateMeshNode rehydrates the warm key
before the check), so the mirror had a gap: for a node evicted to the
warm tier whose cache slot was also gone, an attacker's NodeInfo with a
bogus key passed the pin, and the cache's TOFU pin then locked the
genuine node's frames out until the poisoned entry aged away.

Split the authoritative lookup out of NodeDB::copyPublicKey() as
copyPublicKeyAuthoritative() (hot store, then warm tier - never the
opportunistic TMM tier, which would compare the cache against itself)
and pin against that.

Test: warm-tier-only key rejects a mismatching NodeInfo and accepts the
matching one. Native suite: 61/61.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KWHDDxY4U2XLYW2WJJ2oLT
(cherry picked from commit 28e880875ef1f8bf16bdf301a4e6036537a84b4b)

* Convert NodeInfo cache clocks to uint8 tick counters

Replace the entry's uint32 millis stamps (lastObservedMs,
lastResponseMs) with three uint8 free-running tick clocks - the same
modular scheme the UnifiedCache counters already use:

  obsTick   3 min/tick (12.8 h period) - replay staleness gate
  respTick  5 s/tick   (21.3 min)      - per-target response throttle
  retTick   1 h/tick   (10.67 d)       - retention TTL + LRU age

Validity is an explicit flag bit (hasObserved / hasResponded), not a 0
sentinel, and the maintenance sweep saturates a stamp (clears its flag)
once its window passes, so no stamp can age toward its ~256-tick
aliasing horizon. That retires the wrap/sentinel special cases (T4, T9)
- nowStampMs() survives only for the fallback path's module-global
millis stamp. obsTick is separated from retention by design: only a
genuinely heard NODEINFO frame stamps it, so later membership-based
retention refresh can never make a silent node look servable.

Also drops lastObservedRxTime, which was only echoed in a debug log.

Entry shrinks 136 -> 128 B; the 2000-entry array 272 kB -> 256 kB, 8 kB
below the original develop footprint while keeping the throttle and
retention features. Tick granularity costs at most one tick per window
(+-5 s on 30 s, +-3 min on 6 h, +-1 h on 7 d).

Native suite: 61/61.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KWHDDxY4U2XLYW2WJJ2oLT
(cherry picked from commit e48ed9cfacfed0131b77ed2f1992e8720720d387)

* Keep the NodeInfo cache a superset of NodeDB (seed + keep-alive)

The cache learned identities only from overheard NODEINFO frames, so its
membership was opportunistic: a NodeDB-tier node whose frame was never
heard this boot had no entry (no key pin to protect it, no name to
rehydrate), and the retention TTL evicted entries for nodes that still
lived in the hot store or warm tier.

Add an anti-entropy pass to the maintenance sweep
(reconcileNodeInfoMembershipLocked): every hot-store / warm-tier node
gets an entry - full identity from the hot store (hasFullUser), key-only
from the warm tier - with signer provenance inherited key-matched, and
NodeDB's key adopted wholesale on conflict. Member entries (isMember)
are re-stamped each sweep, so they never age toward the retention TTL;
when a node leaves both tiers the keep-alive stops and its entry ages
out from its final member re-stamp. Membership also outranks key trust
in LRU victim selection, and the reconcile pass skips seeding rather
than churn one member out for another when hot+warm exceeds the cache.

Two properties are load-bearing:
 - seeding/keep-alive never touch obsTick/hasObserved, so a seeded or
   retained entry is never served as a spoofed reply - only genuinely
   heard frames make a node servable;
 - copyUser() now requires hasFullUser, so a key-only warm seed can
   never stamp HAS_USER onto a nameless node via name-rehydration.

Native suite: 64/64.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KWHDDxY4U2XLYW2WJJ2oLT
(cherry picked from commit 4f1b30d838b2f7b11173373b8f1ba97cf91bef6b)

* Write-through NodeDB identity commits into the NodeInfo cache

updateUser() is the single chokepoint through which every remote
identity/key write enters NodeDB (key-verification keys stay in
CryptoEngine's pending buffer until a NodeInfo carries them here), so
one hook at its tail lets the NodeInfo cache reflect a commit
immediately instead of waiting up to a minute for the reconcile sweep -
which stays in place as the anti-entropy backstop.

onNodeIdentityCommitted() upserts the full User: NodeDB's key is
authoritative (a conflicting cached key is stale residue - replaced,
provenance dropped), a keyless commit keeps an already-cached TOFU key,
and signer provenance transfers only for the committed key. The
observation stamp is never touched: knowledge is not observation, so a
hook write can never make a never-heard node servable - covered by the
new test alongside immediate copyUser/copyPublicKey visibility.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KWHDDxY4U2XLYW2WJJ2oLT
(cherry picked from commit e160b899728be8d951d4e7bdf5106c777832b917)

* Purge TrafficManagement caches on explicit node removal

NodeDB::removeNodeByNum() already forgets the node everywhere NodeDB
owns - hot store, satellite stores, warm tier - but the TrafficManagement
caches kept the deleted identity: the NodeInfo entry went on feeding the
key pool (NodeDB::copyPublicKey) and name rehydration, and the unified
slot kept its role/next-hop/dedup state, resurrecting the node on next
contact.

Add purgeNode(): clears both the unified cache slot and the NodeInfo
cache entry, called from removeNodeByNum() alongside the warm-tier
removal. Removal means full removal; passive eviction never calls this,
and the reconcile sweep will not re-seed a node absent from both NodeDB
tiers.

Test: an observed identity plus a next-hop hint both vanish after
removeNodeByNum(). Left for CI to execute per request.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KWHDDxY4U2XLYW2WJJ2oLT
(cherry picked from commit b5564c173337de99e15ef998a5c698550238fd3c)

* Document the tmm-fix-2 / tmm-fix-superset reconciliation decisions

Two parallel implementations of the same superset design are being
combined on this branch. This decision matrix records every divergence
and which side each reconciled feature takes, ahead of the code commits
that apply them.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KWHDDxY4U2XLYW2WJJ2oLT

* Reconcile retention: no timed eviction, obsTick LRU, hourly seeding

Adopt tmm-fix-superset's retention model (reconciliation decisions #3,
#4, #9 in .notes/tmm-super-superset-reconciliation.md):

- Entries are never evicted on a timer. The 7-day retention TTL and its
  third tick clock (retTick) are gone; wrap-safety rests entirely on the
  sweep's presence-bit saturation, and a quiet entry keeps its value
  (pubkey pool, name rehydration). Slots are reclaimed only by
  trust/membership-tiered LRU on insert - age scored by obsTick, with
  never-observed entries counting oldest - or by an explicit purge.
- Membership refresh (entry -> NodeDB contains checks) runs every sweep;
  the heavy seeding pass runs at boot and then hourly, with the
  write-through hooks carrying immediacy in between.
- Tick constants and helpers move into the header beside the existing
  UnifiedCache tick idiom, with static_asserts tying the tick windows to
  the fallback path's seconds/ms forms.

Kept from tmm-fix-2 (decision #4): the warm tier is still seeded
(key-only records via WarmNodeStore::entryAt) so the invariant remains
cache superset-of hot AND warm, and the spareMembers guard still stops
seeding from churning one member out for another when hot+warm exceeds
the cache. Entry shrinks to 128 B (2000 entries = 256 kB).

The TTL-based retention test is superseded by a no-timed-eviction test:
a quiet keyed entry survives 9 days of sweeps while the serve gate
saturates as before.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KWHDDxY4U2XLYW2WJJ2oLT

* Reconcile hooks: key-commit write-through, purgeAll, key-matched signer

Adopt tmm-fix-superset's hook coverage (reconciliation decisions #5-#8):

- onNodeKeyCommitted(): write-through for the two key-write sites that
  bypass NodeDB::updateUser - the Router's admin-key learn (TOFU-grade)
  and KeyVerificationModule's manual-verification commit (proven=true,
  the strongest provenance the cache can carry). Both were coverage gaps
  in the tmm-fix-2 write-through design.
- updateUser's hook call now transfers signer status key-matched
  (isVerifiedSignerForKey) instead of the node's bare signed flag, and
  runs on acceptance rather than only on change.
- purgeAll(): factoryReset() and resetNodes() clear both TMM tables -
  removal-is-full-removal applies to bulk resets too, without relying on
  the usual post-reset reboot.
- purgeNode() reuses the existing finders instead of open-coded scans
  and logs the (user-initiated) purge.
- peekNodeInfoFlagsForTest(): flag introspection so saturation and
  membership tests can assert sweep effects directly.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KWHDDxY4U2XLYW2WJJ2oLT

* Merge the two branches' test suites for the reconciled design

Port tmm-fix-superset's unique tests, adapted to run natively under
TMM_HAS_NODEINFO_CACHE (reconciliation decision #11):

- keyHook_upsertsAndGovernsProvenance: TOFU learn -> manual-verification
  upgrade -> NodeDB-senior rotation resets provenance.
- tickSaturation_sweepClearsObserved: the sweep saturates hasObserved
  past the serve window, the entry persists (no TTL), and a full
  256-tick clock wrap cannot alias a saturated stamp back to fresh.
- sweepMembershipMarking: reconciliation seeds a hot identity as
  member+fullUser+unobserved; the next sweep clears membership once the
  node leaves NodeDB, while the entry itself persists.

tmm-fix-2's tests (warm pin, hot/warm seeding, updateUser write-through,
removal purge - now also asserting the entry is gone via the new peek
hook - and the no-timed-eviction rewrite) were already on this branch.
Suite now counts 70 test functions.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KWHDDxY4U2XLYW2WJJ2oLT

* Adapt cherry-picked and seeding tests to the reconciled semantics

Two tests needed updating for interactions the validation run surfaced:

- The #11035 test (ignoresUnsignedSignerIdentity) was written for a
  world without the native NodeInfo cache: it needs the NodeDB fallback
  path (dropNodeInfoCacheForTest) and the signed replay gate satisfied
  for its target, like the other fallback tests on this branch.
- The hot-seed test's "observed frame makes it servable" step now sends
  a signature-verified frame: its node is a known signer, and per #11035
  an unsigned frame from a signer must not (and does not) drive cache
  writes - the gate working as designed.

Full native suite: 70/70 under ASan.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KWHDDxY4U2XLYW2WJJ2oLT

* trunk fml

* Doc tidyup

* TrafficManagement: key NodeInfo-cache maintenance to its own guard

runOnce() nested the entire NodeInfo maintenance block (tick-stamp
saturation, membership refresh, boot/hourly reconcile) inside
#if TRAFFIC_MANAGEMENT_CACHE_SIZE > 0, so a variant overriding the
unified cache to 0 on a PSRAM board would compile the NodeInfo cache
without its maintenance: hasObserved would never saturate, obsTick
would alias past its 12.8 h wrap, and the 6 h staleness gate - whose
wrap-safety explicitly depends on the sweep - would serve spoofed
replies for long-gone nodes. Extract maintainNodeInfoCacheLocked(),
guarded by TMM_HAS_NODEINFO_CACHE alone, and give runOnce() the same
independent-guard structure purgeAll() already has.

Also close the runtime cousin of the same mismatch: the write-through
hooks (onNodeIdentityCommitted / onNodeKeyCommitted) now no-op while
moduleConfig.has_traffic_management is off. Previously they kept
filling the cache from NodeDB commits while runOnce() returned
INT32_MAX, accumulating entries that were never swept or reconciled.
Purges and reads stay ungated: removal must always work, and reads
just miss an empty cache.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* TrafficManagement: forward throttled NodeInfo requests instead of consuming them

The direct-response throttle returned true, which made handleReceived()
STOP the request: within the 30 s window a repeat request was neither
answered nor forwarded toward the genuine target, and the call site
still logged "respond" and bumped nodeinfo_cache_hits for a reply that
was never sent. A requester whose first reply was lost on a noisy link
got silence for the whole window.

Return false instead: the request flows through normal relay handling,
so the genuine node (or another cache-holder) can answer, while our own
spoofed TX stays bounded. Repeats of the same packet id are already
absorbed by the router's duplicate detection, and the stats counter now
only counts replies actually sent.

Also log purgeNode() only when a slot was actually cleared - it runs
for every NodeDB removal, including nodes the caches never tracked.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* TrafficManagement: consolidate NodeInfo-cache #if sprawl into one region

Fourteen scattered #if TMM_HAS_NODEINFO_CACHE guards (one per function,
each with its own #else stub of (void) casts) collapse into a single
guarded region holding every NodeInfo-cache-only function, terminated
by one #else block of no-op stub definitions. Because the stubs now
exist on every build, the call sites in runOnce(), purgeNode() and
purgeAll() drop their guards too; inner guards remain only for
orthogonal features (PKI, warm tier) and for real conditional work
(constructor allocation, PSRAM paths in shouldRespondToNodeInfo).

No behavior change. Compile-checked on both sides of the macro: the
native app build (stubs) and the native unit-test build (region).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Router: resolve sender key only for PKI-decrypt candidates

perhapsDecode() resolved the sender's public key unconditionally for
every encrypted packet, before even checking whether the packet could
be PKI-decrypted (channel 0, unicast to us). Since copyPublicKey()
grew its TrafficManagement fallback tier, a full hot+warm miss - the
common case for channel traffic from senders outside both NodeDB
tiers - additionally walked the 2000-entry NodeInfo cache under its
lock, per packet, for a key that was then discarded.

Move the resolution inside the PKI-candidate branch; remotePublic and
haveRemoteKey had no consumers outside it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* TrafficManagement: refresh NodeInfo membership hourly, not per-sweep

The 60 s sweep re-derived isMember with a per-entry NodeDB lookup:
O(entries x members) - about 700k node-number comparisons per minute on
a full PSRAM cache (2000 entries x 250 hot + 100 warm), with strided
PSRAM reads, all while holding cacheLock against the packet path.

Move the refresh into the hourly reconcile pass, which is already
O(members x entries): after the seeding loops (so the upsert pass still
sees last pass's bits for spareMembers protection), clear every
isMember bit and re-mark from both NodeDB tiers - including keyless
warm records, which seed nothing but are still members.

Accepted tradeoff, now documented on the field: membership lags a
passive NodeDB eviction by up to an hour (the entry just stays
LRU-sticky slightly longer). Additions stay immediate via the
write-through hooks, explicit removals via purgeNode().

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* NodeDB: centralize bare-key commits in commitRemoteKey()

The two key-write sites that bypass updateUser() (admin-channel learn
in Router::perhapsDecode, manual verification in KeyVerificationModule)
each carried their own #if HAS_TRAFFIC_MANAGEMENT write-through
boilerplate - the pattern this PR itself had to retrofit twice, and
which any future direct key write would silently miss, leaving the
TrafficManagement cache divergent until the next hourly reconcile.

Add NodeDB::commitRemoteKey(n, key32, KeyCommitTrust): writes the key
to the hot store and routes the TrafficManagement write-through in one
place, with provenance explicit at the call site (AdminChannelProven
maps to TOFU-grade, ManuallyVerified to proven). The bypass sites keep
their reason for existing - bare-key commits with provenance that
updateUser's User-payload/TOFU-pin path cannot express - but no longer
know about TrafficManagement at all; both stale includes are dropped
(Router's was already unused on develop).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* docs: tick wrap-safety analysis, updated cadences, CodeRabbit doc fixes

node_info_stores.md gains a "Tick clocks and wrap safety" section
recording which mechanism keeps each modular tick clock honest: the
unified-cache clocks pair the 60 s sweep with read-time window resets,
the NodeInfo obs/resp clocks are sweep-only (hence the compile-time
invariant that maintainNodeInfoCacheLocked() is guarded by
TMM_HAS_NODEINFO_CACHE alone), and the warm tier is immune by design -
absolute unix-seconds, no wrap until 2106.

Also brought current with this series: membership refresh moved to the
hourly reconcile, throttled direct-response requests forward instead of
being consumed, the commitRemoteKey() bare-key funnel, and the
module-disabled gate on the write-through hooks.

CodeRabbit doc review (PR #11050): present the NodeInfo payload cache
as the third *identity* tier with the unified cache beside the chain,
and describe NodeInfoLite's flattened fields / satellite copy-out
accessors instead of the removed nested members. Two stale
docs/tmm_node_stores.md references in the header now point at the real
file.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* TrafficManagement: adapt tests to forwarded throttles and hourly membership

Throttle tests (PSRAM and NodeDB-fallback paths): a request inside the
30 s window now CONTINUEs into normal relay handling instead of being
consumed - assert no spoofed TX, no NAK suppression, and (PSRAM path)
that nodeinfo_cache_hits counts only replies actually sent.

Membership test (renamed reconcileMembershipMarking): the per-minute
sweep no longer refreshes isMember, so the test now pins down both
halves of the new contract - the very next sweep after a passive NodeDB
eviction still shows the stale member bit (the documented up-to-an-hour
lag), and a reconcile interval's worth of sweeps clears it.

Disabled-module test additionally proves the write-through hooks share
the has_traffic_management gate: a key commit while disabled must not
land in the NodeInfo cache.

Not covered here: a build permutation with TRAFFIC_MANAGEMENT_CACHE_SIZE
overridden to 0 (the configuration the maintenance-guard fix protects)
would need its own PlatformIO env plus guards on every unified-cache
test - left as a follow-up.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* expand test coverage

* nitpicks and docs update

* more comment fixes

* nitpicks

* test: make TMM fixture cleanup abort-safe in tearDown()

Several tests reset per-test global state (trafficManagementModule pointing at a
stack `module`, owner.public_key, the RTC fake clock) only after their assertions.
A TEST_ASSERT_* failure longjmps out and skips that trailing cleanup, leaving the
state to dangle into later cases - concretely, the next setUp()'s resetNodes()
would call trafficManagementModule->purgeAll() on a destroyed object.

Move the resets into tearDown(), which runs unconditionally between tests, and drop
the now-redundant per-test cleanups. Addresses a CodeRabbit review comment on
PR #11050.

clod helped too

* docs tidy

* Throttle NodeInfo direct responses

A direct response is addressed to the requesting packet's from field, which
is unauthenticated, and is sent by every neighbour that holds the target in
cache. One request therefore makes several nodes transmit at an address the
requester chose, and nothing limited how often that could be repeated.

Replies are now spaced per requester, which bounds how much any single node
can be made to receive, plus a global floor on how much airtime the feature
can consume. The check sits where a reply is about to be sent, so requests
declined for other reasons do not consume the budget.

* test: reconcile TMM direct-response tests with #11104 throttle

Makes the suite green for now. #11104's per-requester + global-floor
throttle (60 s) is layered over the branch's per-target throttle, so the
three existing "served again" checks (psram, fallback, sweep) now use a
fresh requester to avoid the per-requester window masking the mechanism
each one actually exercises. Adds a dedicated test for the new
per-requester + 1 s global-floor behaviour (the reflector-flood gap the
per-target throttle leaves open, and the bound that survives on
non-PSRAM builds).

Deferred: the branch's now-redundant fallback global stamp
(nodeInfoFallbackLastResponseMs) is left in place as a follow-up cleanup.

Clod wants credit.

* TMM: unify direct-response throttles into per-sender + per-target RAM tables

Replace the two path-specific NodeInfo-spoof throttles (per-entry respTick on
the PSRAM cache path, single global stamp on the fallback path) with three
symmetric bounds that behave identically with and without PSRAM:

  - per requester (60 s): how much any one node can be made to receive;
  - per target   (60 s): how often we vouch for the same identity;
  - global floor (1 s):  total airtime, the backstop once an attacker cycles
    requester/target past the 8-slot tables.

Both axes are fixed 8-slot LRU tables in internal RAM (not the PSRAM NodeInfo
cache), compared by wrap-safe uint32 ms subtraction, so there is no tick clock
and no sweep to maintain. directResponseAllowed() resolves both slots before
stamping either, so a reply one axis throttles never consumes the other's
budget, and records the send itself.

Retires: nodeInfoFallbackLastResponseMs, kNodeInfoResponseThrottleMs,
nowStampMs, the respTick byte + hasResponded bit on every cache entry,
currentRespTick/kNodeInfoRespTickMs/kNodeInfoThrottleTicks, and the sweep's
respTick clear. Renumbers peekNodeInfoFlagsForTest (drops the responded bit).

Tests: the psram/fallback throttle tests now assert the per-target axis at 60 s,
isolated via a different requester, proving it holds with and without PSRAM;
perRequesterAndGlobalFloor isolates the other two axes; the obsolete respTick
wrap-safety test is removed. 83/83 native cases pass.

clod helped too

* whats up, doc?

* trimming the comments

* test: bump native-suite-count to 38 after upstream rebase

Upstream develop carries 38 test_* suite directories but its
native-suite-count file still reads 37 (a suite was added without
bumping the count). Rebasing onto develop inherits that stale file,
so the runner flags AMBER. Correct the registered total to 38.

clod helped too

---------

Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: Thomas Göttgens <tgoettgens@gmail.com>
2026-07-21 12:19:43 +02:00
Ben Meadors 0165e914a9 fix: gate module replies by request port (#11094) 2026-07-21 10:19:38 +02:00
Thomas Göttgens 7bdc2569e8 Budget the admin-key PKI decrypt fallback (#11100) 2026-07-21 09:48:09 +02:00
Thomas Göttgens 40e583c102 Limit node database churn from unauthenticated packets (#11102) 2026-07-21 09:09:53 +02:00
Thomas Göttgens d9f8839241 Accept a pending key only for the key-verification exchange (#11107)
perhapsDecode fell back to the not-yet-verified key held during a key
verification handshake for any incoming PKI unicast. That key is supplied
by whoever opened the handshake and proves only that they hold it, not that
they are the node they claim to be, so until the session ended they could
send DMs on any port that decrypted and were marked pki_encrypted.

perhapsEncode already restricts the pending key to KEY_VERIFICATION_APP.
The receive path now applies the same rule.
2026-07-20 20:28:30 -05:00
Thomas Göttgens fb75410e53 Null-check packet allocations in allocForSending and its callers (#11086)
* Null-check packet allocations in allocForSending and its callers

Sibling of 0ae44d701.

* Null-check allocDataProtobuf, allocAckNak and allocErrorResponse callers

Second tier of the same nullable contract.

* Keep telemetry sleep scheduling on allocation failure

Allocation failure now marks the telemetry invalid instead of returning
early, so power-saving SENSOR nodes still arm deep sleep.
2026-07-20 18:37:13 -05:00
Benjamin Faershtein 08cfb3d683 Merge upstream develop into packet authentication policy 2026-07-20 11:48:59 -07:00
Thomas Göttgens 290967f739 Release packets the interface declines to send (#11087) 2026-07-20 13:42:59 +02:00
Thomas Göttgens 5ded0ec8c5 Gate identity learning on signature in NodeDB::updateUser (#11084) 2026-07-20 11:56:42 +02:00
Thomas Göttgens 405a6bfd8a Strip inner-message padding when sizing unsigned broadcasts (#11083) 2026-07-20 11:55:55 +02:00
Thomas Göttgens 023351a979 Remove dead code (#11082)
Removed:
- MessageStore::addFromPacket and addFromString, superseded by
  tryAddFromPacket
- GeoCoord rangeRadiansToMeters, distanceTo, bearingTo
- Router::rawSend, declared virtual with no override and no caller
- ContentHandler handleHotspot, handleFs, handleAdminSettings,
  handleAdminSettingsApply, handleDeleteFsContent and their commented
  route registrations, plus the now unreachable htmlDeleteDir and the
  handleUpdateFs declaration that had no definition
- ContentHelper replaceAll
- OnScreenKeyboardModule popup chain: showPopup, clearPopup, drawPopup,
  drawPopupOverlay and their state, unreachable since the frame based UI
  was replaced by baseUI
- DebugRenderer drawDebugInfoTrampoline, drawDebugInfoSettingsTrampoline
  and the orphaned drawFrameSettings
- NodeListRenderer calculateMaxScroll, drawColumns and a stale extern
  haveGlyphs declaration with no definition
- UIRenderer::haveGlyphs, Screen::blink,
  NotificationRenderer::showKeyboardMessagePopupWithTitle,
  VirtualKeyboard::getInputText
- InkHUD touchNavLeft, touchNavRight, Applet::getActiveNodeCount,
  ThreadedMessageApplet::saveMessagesToFlash
- TwoButton::setHandlerUp, TwoButtonExtended setHandlerUp,
  setJoystickDownHandlers, setJoystickUpHandlers
- CannedMessageModule LaunchRepeatDestination, isCharInputAllowed,
  hasMessages
- TrafficManagementModule resetStats, recordRouterHopPreserved,
  saturatingIncrement
- UnitConversions::MetersPerSecondToMilesPerHour
- EncryptedStorage getSessionRemainingSeconds
- BMI270Sensor::writeRegisters, GPS::hasFlow, FSCommon copyFile,
  SerialConsole consolePrintf, buzz playLongPressLeadUp,
  memGet displayPercentHeapFree
2026-07-20 11:55:17 +02:00
Benjamin Faershtein 59ab12675a Merge develop into packet authentication policy 2026-07-19 15:33:16 -07:00