Files
meshtastic_firmware/test/test_packet_signing/test_main.cpp
T
fdb644e0b7 Fix millis() rollover in deadline, interval, and timestamp handling (#11291)
* Add native test coverage for the UptimeClock monotonic seam

src/UptimeClock.{h,cpp} shipped without a dedicated test suite. Port the six
tests from the monotonic-time branch (test/test_time), retargeted to the
renamed header.

The wrap test crosses 0xFFFFFFFF via advanceTestMillis() rather than a second
setTestMillis(): setTestMillis() sets clockSourceChanged, which makes
getMillis64() rebase its accumulator and swallow the wrap.

* NextHopRouter: fix 49.7-day millis() rollover in retransmission timing

Resolves the "FIXME, handle 51 day rolloever here!!!" in
NextHopRouter::doRetransmissions() by switching the retransmission-due
comparison from plain unsigned <= to a signed-difference cast.

The previous p.nextTxMsec <= now comparison silently breaks across the
~49.7 day millis() wraparound: pending retransmissions either stall
for the remainder of the wrap window, or all fire simultaneously at
the rollover boundary. Long-running router/infrastructure nodes do hit
this in practice.

The replacement (int32_t)(p.nextTxMsec - now) <= 0 is the standard
Arduino/embedded idiom for rollover-safe deadline checks and behaves
identically to the original for any non-wrap timing.

* Address Copilot review: use unsigned half-range for rollover-safe retransmit check

Review feedback from @Copilot on PR #10227: casting a uint32_t
subtraction to int32_t is implementation-defined in C++ when the
unsigned value exceeds INT32_MAX (even though it works on typical
two's-complement targets).

Switch to the fully well-defined unsigned half-range form:
  nextTxMsec is in the past-or-equal iff (now - nextTxMsec) has not
  wrapped past 2^31 ms. Future offsets < 2^31 ms wrap into the top
  half and read as 'not yet'.

Same semantics as the signed-cast version on every two's-complement
platform we care about, but portable to any conforming C++ impl.

* Use monotonic time for airtime windows

* Document monotonic airtime windows

* Fix test_packet_signing sentinel that #10227's rollover fix inverts

test_C3_invalid_repeated_packet_cannot_ack_or_change_retry_state parked a
pending packet at nextTxMsec = UINT32_MAX to mean "never retransmit", then
asserted that a rejected repeated packet leaves the retry state untouched.

NextHopRouter::doRetransmissions() now tests whether a retransmit is due with
an unsigned half-range compare, (uint32_t)(now - nextTxMsec) < 0x80000000u,
so that retransmission timing survives the ~49.7 day millis() wrap. Under it
now - 0xFFFFFFFF == now + 1, a small positive delta, so UINT32_MAX reads as
~1ms in the past: the retransmit fires and rewrites nextTxMsec, and the test
failed with "Expected 4294967295 Was 6247".

Use a representable future time instead. Production is unaffected either way -
nextTxMsec is only ever written as millis() + d, and UINT32_MAX came from the
test harness alone - so the sentinel is what needs to go, not the comparison.
Special-casing UINT32_MAX in the retransmit path would keep a value that reads
as "expired" under any wrap-correct compare.

The value is held in a local because millis() advances across
runPipelineIngress(), so recomputing it at the assertion would compare against
a different number.

Reported upstream on meshtastic/firmware#10227, whose branch predates this test.

* Make Throttle time-injectable and add hasElapsed()

Throttle backs ~94 call sites, which makes it the highest-leverage place in
the tree to put the clock seam: reading Time::getMillis() instead of millis()
in its three call sites turns all of them into time-injectable code at once,
without touching any of them. The 32-bit millis() wrap is not otherwise
reachable from a native test.

The read is behaviour-preserving - Time::getMillis() returns millis() unless a
test injects a clock - and the full native suite passes with it live.

Also add hasElapsed(), the complement of isWithinTimespanMs(), because 51 of
the 94 call sites are spelled !isWithinTimespanMs and read poorly. Its
boundary is inclusive (>=) since isWithinTimespanMs uses <; both are
documented. It deliberately does not treat lastExecutionMs == 0 as "never
run": call sites pair that test with the interval check themselves, and
absorbing a sentinel into the one helper every module depends on is exactly
the value-overloading hazard being removed elsewhere.

Migrating the existing !isWithinTimespanMs sites is cosmetic and deliberately
left out of this commit.

test/test_throttle/ covers window semantics, both boundaries, the complement
identity, execute()'s first-run and throttled paths, and - the point of the
exercise - a window opened before the wrap closing correctly after it,
including at the 24h interval that is the longest in the tree.

* Stop disarmed deadline sentinels reaching the comparison

Two deadline variables encoded "inactive" as a magic value that only reads as
"never" because the comparison against it is a naive millis() compare. Under
any rollover-correct comparison both invert to "expired ~49 days ago", so they
have to be untangled before those comparisons can be fixed.

Power::reboot() set rebootAtMsec = -1 on platforms with no reboot
implementation, intending "never fire". Every reader already treats 0 as the
disarm value - powerCommandsCheck() tests `if (rebootAtMsec && ...)`, and
AdminModule writes 0 to cancel - so -1 was both wrong and unnecessary. Use 0.
Left as UINT32_MAX it would reboot-loop the moment the comparison is corrected.

ExternalNotificationModule's nag window compared against nagCycleCutoff, which
holds UINT32_MAX once stopped and 1 at boot. isNagging is the real armed flag,
so test it first and short-circuit: a disarmed cutoff can no longer reach the
arithmetic, while an idle module still takes the same sleep path that the
boot-time value of 1 was relying on.

Note this fixes the sentinel only. The comparison itself is still a naive
`nagCycleCutoff < millis()` and remains on the list to convert.

* Fix millis() rollover in every deadline and interval comparison

Roughly 20 sites compared against millis() directly - `millis() > deadline`,
`deadline < millis()`, `last + interval < millis()`. All of them break for
about 24 days after the 32-bit millis() wrap: depending on which side of the
wrap each value sits, the action either stalls for weeks or fires immediately
and repeatedly. The longest affected interval is the 12 hour NTP renewal, a
~50x margin against the wrap, so none of these needed the range - only the
correct comparison.

Add Throttle::deadlinePassed(deadlineMs) for sites that store an absolute
deadline they cannot re-express as "interval since an event". It uses the same
unsigned half-range test as NextHopRouter::doRetransmissions() rather than
introducing a competing signed-cast idiom, and unlike the signed cast it is
defined for every input. Sites that do store an event use the existing
isWithinTimespanMs / hasElapsed. Nothing gained new state.

Because both helpers read Time::getMillis(), every converted site is now
reachable from a native test that drives the clock across the wrap; the
comparison itself is covered directly in test/test_throttle/.

Sentinel handling is the reason this could not be a mechanical rewrite. The
disarm convention is not uniform: 0 means "inactive" for rebootAtMsec,
shutdownAtMsec, alertBannerUntil, fixHoldEnds, suppressUntilMs and
touchResumeBlockUntilMs; 0 means "due now" for ntp_renew, which is forced to 0
at link-up; UINT32_MAX means "inactive" for nagCycleCutoff; and
alertBannerUntil == 0 in isOverlayBannerShowing() means "show indefinitely".
Every inactive marker is arithmetically far in the past, so a correct
comparison fires on it - each site tests its sentinel before the arithmetic,
and keeps the meaning it had.

Two sites carried a second bug found on the way:

BME680Sensor tested (stateUpdateCounter * STATE_SAVE_PERIOD) < millis(). With
a 6 hour period and a uint16_t counter that product overflows uint32_t after
about 198 saves, independently of the millis() wrap. It now measures the
interval since the last save.

EInkDynamicDisplay had `if (previousRunMs > millis()) return;` as a millis()
overflow guard, which skipped rate limiting entirely for the whole post-wrap
period - the bug it meant to prevent. Every check below it already goes
through Throttle, so the guard is removed rather than fixed.

MotionSensor's calibration countdown is converted to a signed delta rather
than deadlinePassed, because it needs the remaining magnitude and not a
boolean; that matches the already-correct check in the same file.

* Remove getMillis64() and use Throttle for the NodeInfo reply window

getMillis64() had exactly one caller and no callers in tests. It also carried
obligations that made it the wrong shape for this firmware: a wrap accumulator
in mutable statics, which is not ISR-safe, and which must be polled at least
once every ~49.7 days or it silently misses a wrap and returns a time ~49 days
short.

Its one caller only wanted to know whether a 12 hour suppression window had
elapsed - which Throttle answers correctly across the wrap without any
accumulator. NodeInfoModule now stores Time::getMillis() in lastNodeInfoSeen
and tests the window with Throttle::isWithinTimespanMs, so the map holds
milliseconds rather than seconds derived from a 64-bit read.

USERPREFS_NODEINFO_REPLY_SUPPRESS_SECS is user-overridable and now feeds a
multiply by 1000, so a static_assert rejects any value too large to express in
milliseconds instead of letting it wrap.

clockSourceChanged goes too. It existed solely to rebase getMillis64()'s
accumulator when a test swapped clock sources, and it made the wrap untestable
through the injection API: setTestMillis() set the flag, so a wrap crossed by
two setTestMillis() calls was swallowed. With the accumulator gone the flag has
nothing to rebase, and the injection API is a plain settable clock.

The three getMillis64 tests are dropped as they no longer describe anything.
One test replaces them, pinning that advanceTestMillis() wraps past
0xFFFFFFFF rather than saturating, since the Throttle wrap tests rely on it.

Also fix eviction in pruneLastNodeInfoCache(): it picked the entry with the
smallest stored stamp, which is the wrong victim once some stamps sit on the
far side of the wrap. It now evicts the largest elapsed time.

* Add CI guard and docs rule against naive millis() comparisons

Fixing the existing sites does not stop the next one being added. The
millis-deadline-check job rejects millis() placed directly next to a comparison
operator, in either order, anywhere in src/. It lives in test_native.yml
alongside suite-count-check, which sets the precedent for a repo-hygiene guard
that CI enforces and bin/run-tests.sh does not.

The correct idioms all subtract before comparing, so none of them match the
pattern. Line comments are stripped first, so documentation is free to name the
broken form - as the guard's own comment and the coding conventions both do.

Writing the check before finishing the sweep turned out to be worth it: it
found roughly 14 sites that a by-hand audit of deadline variables had missed,
including two extra nagCycleCutoff compares, both boot-screen timeouts, and a
6 hour sensor save interval that was also overflowing a uint32_t multiply.

.github/millis-deadline-allowlist.txt covers the cases that are genuinely not
deadline tests. Both current entries are uptime thresholds - "has the device
been up N ms" - with no stored deadline and no event to measure from: a 30s
button holdoff against phantom shutdown from floating pins, and a 10s window
for the OEM boot logo. Each re-crosses its threshold once per wrap, which is
harmless for boot-holdoff logic and not worth new state to avoid. Entries are
keyed on file plus exact source text, without line numbers, so an edit above an
entry does not silently invalidate it.

Locally the guard reports 19 matches before the sweep and 2 after, both
allowlisted.

The Throttle bullet in the coding conventions is rewritten from "prefer
Throttle for rate limiting" to "never compare against millis() directly", lists
all four helpers with when to use which, names the CI guard, and documents the
sentinel hazard with the rebootAtMsec = -1 case that would have become a reboot
loop. Mirrored into AGENTS.md; CLAUDE.md gets a pointer row.

* Trim rollover comments to what the code needs

The comments added with the millis() rollover fixes carried too much of the
investigation that produced them: how many sites were found, which document
recorded them, what the old code used to do. That belongs in the commit history,
not in the source, and some of it was already stale - Power::reboot() still
described the check it disarms as "a naive millis() > deadline" when that
comparison had been fixed in the same series.

What stays is the non-obvious part at each site: which sentinel value the
variable overloads and what it means there, since that differs between call
sites and is what a correct comparison gets wrong. 0 means "not scheduled" for
rebootAtMsec, "renew now" for ntp_renew, and "show indefinitely" in
isOverlayBannerShowing().

Exposition is kept where it earns its place: the Throttle helpers, the uptime
clock's note on why there is no 64-bit variant, and the tests. The Throttle
docs lose only the site count and the "longest interval in the firmware"
statistic, both of which would age badly; the range trade-off between the two
forms is what a caller actually needs.

Comments only - no code changed, verified by diff.

* possible fixes

* Address review feedback on the rollover fixes

- BME680Sensor: checkpoint lastStateSaveMs after a successful write instead of
  at the interval test. The first save (IAQ accuracy >= 2) left it at 0, timing
  the next save from boot, and stamping before the write deferred the retry a
  full period when the write failed. Reads Time::getMillis(), the same clock
  Throttle compares against.

- Throttle: add deadlinePassedAt(now, deadline) for loops that snapshot the
  clock once and test many deadlines; deadlinePassed() now delegates to it.
  NextHopRouter::doRetransmissions() uses it, replacing the inline half-range
  compare adopted from #10227 (nightjoker7) - same arithmetic, credited at the
  call site - and takes its snapshot from Time::getMillis() so setNextTx()
  deadlines and the due test cannot diverge under an injected test clock.

- test_native.yml: set -euo pipefail in the millis-deadline guard, matching the
  sibling suite-count job. Without -e a partially failed scan could report "no
  violations" from truncated output.

- test_packet_signing: build the not-due deadline from Time::getMillis() rather
  than millis(), so the test and the router read one clock.

- test_throttle: cover deadlinePassedAt(), and correct a wrapped-value comment
  (0xFFFFFF00 + 400 is 0x00000090, not 0x00000094).

Two review comments were declined: the AirTime mutex (every airTime-> caller
runs in the single cooperative loop, WebServerThread included) and the
MotionSensor 0-sentinel countdown (the calibration frame is only installed
while a window is open).

clod helped out here

* Correct the described failure window of a naive millis() compare

The comments and agent docs said a bare `millis() > deadline` "breaks for ~24
days after the wrap". That figure belongs to the fix, not the bug: it is the
half-range limit of deadlinePassed(), which reads deadlines more than 2^31 ms
ahead as already passed, and the range over which a UINT32_MAX sentinel reads
as passed.

The naive compare's actual failure is an inversion lasting only while the
deadline sits on the far side of the wrap, so it is bounded by the interval:
the action fires immediately and loses its wait, or blocks for about the wait
it should have performed - days for the nRF52 flash-corruption backoff,
one skipped cycle for a seconds-long retransmit timer.

Comments and docs only; the ~24.8 day statements that correctly describe
deadlinePassed()'s own range are left as they were.

clod helped out here

* Restore a monotonic uptime clock and consolidate the wrap counters

Time::getMillisMonotonic() is the getMillis64() shape - a 32-bit wrap
counter carried across reads - promoted to the shared timebase, with
Time::getUptimeSecs() as the derived whole-seconds view. This deliberately
reverses the earlier removal of getMillis64(), and the distinction matters:
removal was right for a lazily-read accumulator with one rare caller, where
a 49.7-day gap between reads silently swallowed a wrap. Here every read is
the poll and AirTime::runOnce() guarantees one per second; the missed-wrap
contract is pinned by a test rather than left as a footnote.

Three private wrap counters collapse into it:

- AirTime::syncNow() takes its seconds from Time::getUptimeSecs() and drops
  its lastSyncMsec checkpoint; window rotation is unchanged.
- DeviceTelemetryModule loses refreshUptime()/uptimeWrapCount/uptimeLastMs;
  uptime_seconds comes from Time::getUptimeSecs(), which also removes the
  0.296s-per-wrap truncation of (0xFFFFFFFF / 1000) * wraps. Its two
  interval checks move to Throttle::hasElapsed().
- HostMetricsModule's copies of those members were never read (its uptime
  comes from /proc/uptime) - deleted.

Not ISR-safe (unguarded mutable carry): ISRs keep using getMillis(), which
stays a pure read. Audited: no interrupt-context file reads getTime(),
getValidTime(), or the new accessors.

test/native-suite-count 44 -> 45: the bump for test_uptime_clock was lost
in a branch history rewrite, leaving every later value off by one -
run-tests.sh reports AMBER and CI's suite-count-check fails on the current
push until this correction.

* Anchor the wall clock in monotonic milliseconds

getTime() computed elapsed-since-time-set as a 32-bit millis() delta, so a
node that took time once and stayed up past 49.7 days reported a wall clock
one full cycle in the past - and last_heard, rx_time, message and position
stamps all inherited it. The anchor is now the 64-bit monotonic count
(timeStartMsec -> timeStartMs64) and the elapsed term is computed in 64-bit,
so the wall clock is exact at any uptime.

All six anchor writers follow: the five hardware-RTC read branches and
perhapsSetRTC(), which keeps a truncated 32-bit copy of the same instant for
its Throttle-checked rate-limit stamps. The test seams anchor the same way.

Two native regression tests drive getTime() across the wrap through the
Time seam - one anchored before the wrap and read after it, one anchored
after a counted wrap - with the test epoch derived from BUILD_EPOCH so the
plausibility window cannot rot as the build date advances.

* Stamp the rx_time placeholder in monotonic uptime seconds

computeRxTimeStamp() stamped Time::getMillis() when the clock was untrusted,
and reconcilePendingRxTimes() back-calculated with a 32-bit millis() delta -
correct within one wrap, but a placeholder older than 49.7 days aliased to a
small elapsed value and reconciled to a plausible-but-wrong recent epoch:
the exact failure has_rx_time exists to prevent, reachable by an ordinary
unattended router whose phone connects two months in.

The placeholder is now Time::getUptimeSecs(). Both stamps come off the
monotonic counter, so the elapsed term is exact at any age and the aliasing
window is gone outright rather than widened. If elapsed somehow exceeds the
epoch itself, the packet stays un-dated (absent, never wrong) instead of
clamping to a pre-1970 value. Defence in depth: a placeholder that leaks
needs ~50 years of uptime to cross MIN_PLAUSIBLE_EPOCH, where milliseconds
took 18.3 days.

The stream-API reconciliation tests keep their scenarios with the placeholder
unit switched, and ScopedTimeFixture resets the monotonic carry so uptime
seconds are deterministic per case.

* Date nodes heard before the clock arrives, without polluting last_heard

A node first heard while the wall clock was untrusted got no last_heard at
all, and nothing backfilled it once time arrived - the phone showed "Last
heard: unknown" for a node it had just announced. The arrival instant now
waits in a RAM-only sidecar (NodeNum -> uptime seconds, 32 slots,
reuse-oldest - the RouteHealth shape) and is converted to a real epoch on
the clock-becoming-trusted transition, beside the existing rx_time
reconciliation. last_heard itself never holds anything but a real epoch or
0: it persists to flash and the warm tier, where an uptime-relative value
would be meaningless after reboot.

The sidecar's write sites are updateFrom()'s no-trusted-clock path (the
rx_time placeholder already carries the arrival instant, so this is a store,
not a second clock read) and addFromContact's anti-eviction stamps, which
previously wrote a bare getTime() - boot-relative seconds on a clockless
node, the exact value lastHeardIsWallClock() exists to catch. Eviction
ranking honours the stamps: heard-this-boot outranks every stored epoch,
ordered among themselves, so a stamped contact is not the first victim.

PhoneAPI re-reads last_heard at nodeinfo send time: a record prefetched
before the clock became trusted can carry 0 while the store has since been
backfilled, and re-reading at the pop makes handshake ordering (time-set vs
node-list download) irrelevant. Backfill never moves last_heard backwards
and skips the pathological elapsed-exceeds-epoch case. A node evicted to
the warm tier before time arrives is still absorbed with last_heard 0 -
same as before, bounded to the untrusted window.

* Update the agent docs for the monotonic timebase

The conventions bullet asserted there is deliberately no 64-bit millis; the
monotonic uptime clock restored for timestamps changes that contract. State
the split explicitly: Throttle for deadlines and intervals (no carry state),
Time::getMillisMonotonic()/getUptimeSecs() for timestamps, polled by
construction and not ISR-safe.

* Publish the monotonic wrap carry from a single writer

getMillisMonotonic() was a read-modify-write on two unguarded statics, and it
is reached off the main loop: the nRF52 Bluefruit task via
onFromRadioAuthorize() -> PhoneAPI::getFromRadio -> getValidTime(), and the
portduino civetweb workers via the same path. Two readers interleaving inside
the wrap window could each increment the carry, putting every uptime and
wall-clock reading 2^32 ms ahead for the rest of the boot - a permanent ~49.7
day jump in rx_time, last_heard and ClientNotification.time.

Readers no longer write. serviceMonotonic() publishes a snapshot behind a
seqlock and is the only writer; a reader adds its own unsigned elapsed time to
that snapshot, which is exact across the wrap, so it never inspects the
boundary and cannot miscount it. The main loop publishes every iteration, so
the once-per-49.7-days obligation now has the whole window of margin instead of
resting on an instruction-wide race.

AirTime was the guaranteed poller and is now a pure reader, so the two airtime
wrap tests step the clock the way loop() does. The test clock itself is atomic
so a suite can drive it from one thread while others read.

* Re-arm the GPS ephemeris hold when none is in force

The rollover sweep guarded the hold re-arm with `fixHoldEnds != 0 &&`, which
reads like the sentinel rule but inverts this site. The comparison it replaced,
`(fixHoldEnds + GPS_THREAD_INTERVAL) < millis()`, was always true when nothing
was armed - that was the point, since 0 means "not holding" and so is a reason
to arm. With the guard, a publish that cleared the hold without sleeping (the
`shouldPublish && !tooLong && !holdExpired` path, which does not call down())
left hasValidLocation set and prev_fixQual non-zero, so no disjunct held:
nothing re-armed, nothing published, and the receiver stayed powered at the
200ms poll until searchedTooLong() fired.

State the question positively instead. fixHoldInForce() is the only place the
sentinel is interpreted, and both of runOnce()'s decisions derive from it - the
asymmetry is now visible rather than implied, since arming does not require a
prior hold but expiring does. Its `!= 0` test is not redundant with the
arithmetic: deadlinePassed() is an unsigned half-range test, so past 2^31 ms of
uptime the sentinel reads as a deadline ~24.9 days in the future.

Kept beside its caller rather than in a header; the native test build compiles
GPS.cpp, so the suite declares the prototypes.

Also converts the getACK() wait to isWithinTimespanMs(start, interval): it has
both the start instant and the interval in hand, which gives the full 49.7-day
range instead of 24.8 days ahead, and takes its anchor from Time::getMillis()
so the wait is injectable.

* Date the NodeInfo reply window in uptime seconds

The 12h reply-suppression stamp regressed from wrap-immune 64-bit seconds to
raw 32-bit milliseconds, and pruneLastNodeInfoCache() evicts only by node count
and DB membership - never by age. A stable mesh under the node cap therefore
keeps every stamp indefinitely, and once uptime passes 49.7 days an old one
aliases back into the window: `now - stamp` computes as ~0 and a legitimate
NodeInfo request goes unanswered for up to 12h. It self-heals and repeats once
per wrap cycle.

Store Time::getUptimeSecs() instead, which does not wrap for 136 years, and
drop the millisecond conversion the previous shape needed. Entries past the
window are now evicted too: they can only ever decide "don't suppress".

N8-N11 cover the window from both sides, and N10 pins the regression - it needs
a full 2^32 ms of uptime to elapse, not merely a crossing of the boundary,
because that is when a millisecond stamp reads as "answered this instant".

tearDown() now restores the injected clock and C14's region and TX bucket. A
failing assertion aborts the test body, so restoring at the end of it leaked
that state into every later case.

* Update the agent docs for the single-writer clock and sentinel direction

Two rules the preceding three commits changed.

The monotonic clock is no longer maintained by whoever happens to read it:
serviceMonotonic() is the only writer, readers are pure, and calling it from
anywhere but the main loop reintroduces the double-count.

The sentinel guidance gained the half it was missing. It named UINT32_MAX as a
sentinel while prescribing an idiom that only covers 0, and it assumed the
sentinel always means "suppress" - at the GPS fix-hold site it meant "fire",
which is how that regression passed review looking like the rule.

* Name the fix-hold expiry predicate and arm it from the injected clock

holdJustExpired() gives the second reading of the fixHoldEnds sentinel a
name beside the first, so both are pinned by test/test_gps_fix_hold/ and
neither can be respelled at the call site. The old inline form could not
be tested: written as a literal, its guard folds at compile time and the
assertion asserts nothing.

The arm site used bare millis() while the evaluation reads the Throttle
clock; same value in production, but it kept that write out of reach of
Time::setTestMillis(). Remap a deadline that lands on 0, which would
otherwise read as no hold at all.

* Share the extend formula between the clock's reader and writer

getMillisMonotonic() and serviceMonotonic() carried byte-identical wrap
arithmetic. A one-sided edit to either would drift the published carry
from what readers report, so keep one copy.

* Trim the NodeInfo dedup comment to the house limit

* todo note for potential future imrpovments

* fix some simple deadlines

* Trim the hold-expiry test comment to the house limit

* Fix non-blocking uptime publication and pre-clock recency edges (#29)

* fix(time): avoid blocking monotonic readers

* test(time): make paused-publisher check deterministic

* fix(time): address review portability gaps

* Init the eviction sentinel to the newest possible recency

EvictionRecency{} is {0, false}, which evictionRecencyOlder() ranks as older than
every candidate: without the oldestIndex/oldestBoringIndex guards nothing would
ever be selected and a full node DB would stop evicting entirely.

Init to the genuine maximum instead, so the sentinel is correct on its own. The
index guards stay: two independent reasons the scan is right beats one.

* Keep the deadline-guard check name branch protection matches

The guard was widened to cover Time::getMillis() and unqualified getMillis(),
and renamed to suit. Upstream branch protection matches required checks by name,
so a rename means the old name never reports and merges block on a check that
will never arrive.

Widen the guard, keep the name; the descriptive text carries the broader scope.

* Correct native-suite-count to 47 after the develop merge

Upstream #11293 added test_nmea_wpl and took develop's count to 43; this branch
had independently reached 46. Merging develop resolved the counter textually,
keeping 46, while the directory set became the union of both sides at 47.

The suite-count CI gate fails on the mismatch, and it gates the native test jobs,
so the tests themselves were being skipped.

* test(uptime): make the wrap fall where the comment says it does

The concurrent-reader case started at 0xFFFFF000, leaving 0x1000 to the wrap, so
the 0x800 advance annotated "cross the wrap" fell short and the wrap actually
happened during the following 60s advance.

Start at 0xFFFFF800 instead, so the first advance lands exactly on the wrap while
the readers are running and the second is the ordinary time after it - the shape
both comments already described. Total elapsed is unchanged, so the closing
assertion still holds.

* Respond to human comments

* Did I ever tell you about the time I went to Shelbyville? I wore an onion on my belt, which was the style at the time.

* Convert the I2S nag deadline develop dragged in

The HAS_I2S_SPEAKER_NRF52 RTTTL block arrived from develop with a raw
nagCycleCutoff >= millis(), which the deadline guard rejects. Use the same
Throttle::deadlinePassed() form as the two sibling paths in this function.

* Arm the LittleFS format guard with a flag, not a zero timestamp

preFSBegin() runs in the first millisecond of boot, so millis() can legitimately
return 0 there. Both readers of last_format_ms treated 0 as "nothing formatted
this boot", which would skip the repeat-corruption escalation and let a dead
flash reformat-loop instead of reporting FLASH_CORRUPTION_UNRECOVERABLE.

* Note the single-thread contract on AirTime

* Note the AirTime locking TODO, and tighten the thread note

The two constant getters are not constrained, and getSilentMinutes() reads the
buckets without rotating them, so "the accessors mutate" was not accurate.

* trunk: ignore trufflehog false positives on millis-wrap test constants

test_throttle and test_uptime_clock pin dense clusters of hex boundary
constants (0xFFFFFF00u and neighbors) to exercise 32-bit millis()
rollover. trufflehog's Lob detector stitches nearby hex literals into
one candidate string, and the result happens to match a Lob API key
shape - not a secret, just test fixtures.

Same pattern already used for the gitleaks/nodedb-fixture false
positive in this file.

---------

Co-authored-by: nightjoker7 <mattdeering7@gmail.com>
Co-authored-by: Clive Blackledge <clive@ansible.org>
Co-authored-by: Benjamin Faershtein <119711889+RCGV1@users.noreply.github.com>
Co-authored-by: Thomas Göttgens <tgoettgens@gmail.com>
Co-authored-by: Ben Meadors <benmmeadors@gmail.com>
2026-08-12 16:49:17 -05:00

2159 lines
98 KiB
C++

// Tests for XEdDSA packet-signing *policy* - the receive-path accept/reject behavior and the
// send-path signing policy - as opposed to the raw sign/verify primitive (covered in test_crypto).
//
// 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); 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 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 <unity.h>
// The whole suite exercises XEdDSA sign/verify and checkXeddsaReceivePolicy, all of which are
// compiled out unless both PKI and XEdDSA are enabled (e.g. stm32 sets MESHTASTIC_EXCLUDE_XEDDSA).
#if !(MESHTASTIC_EXCLUDE_PKI) && !(MESHTASTIC_EXCLUDE_XEDDSA)
#include "UptimeClock.h"
#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 <ErriezCRC32.h>
#include <cstdio>
#include <cstring>
#include <memory>
#include <pb_decode.h>
#include <pb_encode.h>
#include <vector>
// ---------------------------------------------------------------------------
// Test fixture identifiers
// ---------------------------------------------------------------------------
static constexpr NodeNum LOCAL_NODE = 0x0A0A0A0A;
static constexpr NodeNum REMOTE_NODE = 0x0B0B0B0B;
// A "small" broadcast payload whose signed encoding easily fits a LoRa frame, and an "oversized"
// one whose signed encoding does not, yet still encodes within a LoRa frame unsigned.
static constexpr size_t SMALL_PAYLOAD = 16;
static constexpr size_t OVERSIZED_PAYLOAD = 180;
// ---------------------------------------------------------------------------
// MockNodeDB - inject nodes with controlled public keys / signer bits.
// Mirrors the pattern in test/test_hop_scaling. meshNodes/numMeshNodes are public on NodeDB.
// ---------------------------------------------------------------------------
class MockNodeDB : public NodeDB
{
public:
void installDefaultsPreservingIdentity() { installDefaultConfig(true); }
void clearTestNodes()
{
testNodes.clear();
meshNodes = &testNodes;
numMeshNodes = 0;
}
// Add a bare node and return a stable handle (fetch via getMeshNode so the pointer stays valid
// even if the vector reallocates after later adds).
void addNode(NodeNum num)
{
meshtastic_NodeInfoLite node = meshtastic_NodeInfoLite_init_zero;
node.num = num;
testNodes.push_back(node);
meshNodes = &testNodes;
numMeshNodes = testNodes.size();
}
void setPublicKey(NodeNum num, const uint8_t *pubKey)
{
meshtastic_NodeInfoLite *n = getMeshNode(num);
TEST_ASSERT_NOT_NULL(n);
n->public_key.size = 32;
memcpy(n->public_key.bytes, pubKey, 32);
}
void setSignerBit(NodeNum num, bool value)
{
meshtastic_NodeInfoLite *n = getMeshNode(num);
TEST_ASSERT_NOT_NULL(n);
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<meshtastic_NodeInfoLite> testNodes;
};
static MockNodeDB *mockNodeDB = nullptr;
class AuthPipelineRadio : public RadioInterface
{
public:
ErrorCode send(meshtastic_MeshPacket *p) override
{
sendCalls++;
packetPool.release(p);
return failSend ? ERRNO_DISABLED : 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;
failSend = false;
}
bool failSend = false;
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
// ---------------------------------------------------------------------------
// Build a decoded packet with a deterministic payload of the requested size.
static meshtastic_MeshPacket makeDecoded(NodeNum from, NodeNum to, meshtastic_PortNum port, size_t payloadLen)
{
meshtastic_MeshPacket p = meshtastic_MeshPacket_init_zero;
p.from = from;
p.to = to;
p.id = 0x12345678;
p.channel = 0; // primary channel index (perhapsEncode rewrites this to the channel hash)
p.which_payload_variant = meshtastic_MeshPacket_decoded_tag;
p.decoded.portnum = port;
p.decoded.payload.size = payloadLen;
for (size_t i = 0; i < payloadLen; i++)
p.decoded.payload.bytes[i] = (uint8_t)(i & 0xff);
return p;
}
// Sign a decoded packet with the CryptoEngine's current key - used to simulate a *remote* signer,
// because perhapsEncode only auto-signs packets that originate from us.
static void signWithCurrentKey(meshtastic_MeshPacket *p)
{
bool ok = crypto->xeddsa_sign(p->from, p->id, p->decoded.portnum, p->decoded.payload.bytes, p->decoded.payload.size,
p->decoded.xeddsa_signature.bytes);
TEST_ASSERT_TRUE_MESSAGE(ok, "xeddsa_sign failed in test setup");
p->decoded.xeddsa_signature.size = XEDDSA_SIGNATURE_SIZE;
}
// Encrypt (perhapsEncode) then decrypt+evaluate (perhapsDecode) the same packet in place.
static DecodeState roundTrip(meshtastic_MeshPacket *p)
{
meshtastic_Routing_Error enc = perhapsEncode(p);
TEST_ASSERT_EQUAL_MESSAGE(meshtastic_Routing_Error_NONE, enc, "perhapsEncode did not succeed");
TEST_ASSERT_EQUAL_MESSAGE(meshtastic_MeshPacket_encrypted_tag, p->which_payload_variant,
"perhapsEncode left packet unencrypted");
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)
{
size_t s = 0;
TEST_ASSERT_TRUE_MESSAGE(pb_get_encoded_size(&s, &meshtastic_Data_msg, d), "pb_get_encoded_size failed");
return s;
}
// Would this Data still fit a LoRa frame with a 64-byte signature attached? Mirror of the
// production gate in Router.cpp (signedDataFits / the perhapsDecode downgrade predicate).
static bool signedEncodingFits(const meshtastic_Data *d)
{
meshtastic_Data copy = *d;
copy.xeddsa_signature.size = XEDDSA_SIGNATURE_SIZE;
return encodedDataSize(&copy) + MESHTASTIC_HEADER_LENGTH <= MAX_LORA_PAYLOAD_LEN;
}
// Append a length-delimited field whose tag this build's Data schema does not define, as a sender
// on a newer schema would emit. nanopb skips unknown fields at decode, so these bytes count toward
// the raw wire size but not the decoded struct. Returns the number of bytes appended.
static size_t appendUnknownField(uint8_t *dst, size_t dstLen, size_t contentLen)
{
constexpr uint32_t UNKNOWN_FIELD_NUMBER = 100; // not a field of meshtastic_Data
std::vector<uint8_t> content(contentLen, 0x77);
pb_ostream_t stream = pb_ostream_from_buffer(dst, dstLen);
TEST_ASSERT_TRUE(pb_encode_tag(&stream, PB_WT_STRING, UNKNOWN_FIELD_NUMBER));
TEST_ASSERT_TRUE(pb_encode_string(&stream, content.data(), content.size()));
return stream.bytes_written;
}
// Channel-encrypt raw Data bytes into a packet, exactly as perhapsEncode's non-PKI path does.
// Used to inject wire bytes perhapsEncode would never produce (it only encodes p->decoded).
static void encryptAsChannelPacket(meshtastic_MeshPacket *p, uint8_t *wire, size_t size)
{
const int16_t hash = channels.setActiveByIndex(0);
TEST_ASSERT_GREATER_OR_EQUAL_MESSAGE(0, hash, "no usable primary channel");
crypto->encryptPacket(getFrom(p), p->id, size, wire);
memcpy(p->encrypted.bytes, wire, size);
p->encrypted.size = size;
p->channel = hash; // on the wire the channel field carries the hash, not the index
p->which_payload_variant = meshtastic_MeshPacket_encrypted_tag;
}
// Build A10's frame: an unsigned broadcast carrying a POSITION payload plus unknown fields, sized
// so the raw wire length exceeds the signature-fit threshold while the decoded fields stay under
// it. Channel-encrypted like a normal sender. The asserts pin that split, which is what makes A10
// and A11 meaningful.
static meshtastic_MeshPacket makeBroadcastWithUnknownFields()
{
meshtastic_MeshPacket p = makeDecoded(REMOTE_NODE, NODENUM_BROADCAST, meshtastic_PortNum_POSITION_APP, SMALL_PAYLOAD);
uint8_t wire[MAX_LORA_PAYLOAD_LEN + 1];
const size_t base = pb_encode_to_bytes(wire, sizeof(wire), &meshtastic_Data_msg, &p.decoded);
TEST_ASSERT_GREATER_THAN_MESSAGE(0, base, "failed to encode the base Data");
const size_t raw = base + appendUnknownField(wire + base, sizeof(wire) - base, 160);
// The decoded fields fit a signature, so a sender that signs would have signed this Data.
TEST_ASSERT_LESS_OR_EQUAL_MESSAGE(MAX_LORA_PAYLOAD_LEN, base + XEDDSA_SIGNATURE_FIELD_BYTES + MESHTASTIC_HEADER_LENGTH,
"decoded fields must fit a signature, else the test is vacuous");
// The unknown fields put the raw size over that threshold, so the two sizings disagree here.
TEST_ASSERT_GREATER_THAN_MESSAGE(MAX_LORA_PAYLOAD_LEN, raw + XEDDSA_SIGNATURE_FIELD_BYTES + MESHTASTIC_HEADER_LENGTH,
"unknown fields must push the raw size past the fit threshold");
// The frame is still one a radio could actually send.
TEST_ASSERT_LESS_OR_EQUAL_MESSAGE(MAX_LORA_PAYLOAD_LEN, raw + MESHTASTIC_HEADER_LENGTH, "frame must still fit a LoRa frame");
encryptAsChannelPacket(&p, wire, raw);
return p;
}
// ---------------------------------------------------------------------------
// Unity lifecycle
// ---------------------------------------------------------------------------
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);
while (meshtastic_QueueStatus *queued = pipelineService->getQueueStatusForPhone())
pipelineService->releaseQueueStatusToPool(queued);
resetRoutingAuthEvaluationCount();
}
void tearDown(void)
{
delete mockNodeDB;
mockNodeDB = nullptr;
nodeDB = nullptr;
// Restore globals here, not at the end of a test body: an assertion aborts the body, and these
// would otherwise leak into every later case. The injected clock is the one the N8-N11
// suppression-window cases drive; the region and TX bucket are C14's duty-cycle setup.
Time::useRealClock();
Time::resetMonotonicForTests();
if (airTime)
airTime->utilizationTX[0] = 0;
config.lora.region = meshtastic_Config_LoRaConfig_RegionCode_US;
initRegion();
}
// ===========================================================================
// Group A - receive-side accept/reject matrix
// ===========================================================================
// 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);
mockNodeDB->setPublicKey(REMOTE_NODE, pub);
TEST_ASSERT_FALSE(remoteSignerBit()); // not known as a signer yet
meshtastic_MeshPacket p = makeDecoded(REMOTE_NODE, NODENUM_BROADCAST, meshtastic_PortNum_TEXT_MESSAGE_APP, SMALL_PAYLOAD);
signWithCurrentKey(&p);
TEST_ASSERT_EQUAL(DECODE_SUCCESS, roundTrip(&p));
TEST_ASSERT_TRUE(p.xeddsa_signed);
TEST_ASSERT_TRUE_MESSAGE(remoteSignerBit(), "verified signature must set the signer bit");
}
// 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);
mockNodeDB->setPublicKey(REMOTE_NODE, pub);
meshtastic_MeshPacket p = makeDecoded(REMOTE_NODE, NODENUM_BROADCAST, meshtastic_PortNum_TEXT_MESSAGE_APP, SMALL_PAYLOAD);
signWithCurrentKey(&p);
p.decoded.xeddsa_signature.bytes[0] ^= 0xFF; // corrupt the signature
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
meshtastic_MeshPacket p = makeDecoded(REMOTE_NODE, NODENUM_BROADCAST, meshtastic_PortNum_TEXT_MESSAGE_APP, SMALL_PAYLOAD);
signWithCurrentKey(&p);
TEST_ASSERT_EQUAL(DECODE_SUCCESS, roundTrip(&p));
TEST_ASSERT_FALSE_MESSAGE(p.xeddsa_signed, "cannot be marked verified without a key");
TEST_ASSERT_FALSE_MESSAGE(remoteSignerBit(), "must not learn signer without verifying");
}
// A4: downgrade protection - unsigned small broadcast from a known signer -> dropped.
void test_A4_downgrade_unsigned_broadcast_from_signer_dropped(void)
{
mockNodeDB->addNode(REMOTE_NODE);
mockNodeDB->setSignerBit(REMOTE_NODE, true); // we've seen this node sign before
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_POLICY_REJECT, roundTrip(&p));
}
// A5: no prior knowledge - unsigned small broadcast from a non-signer -> accepted.
void test_A5_unsigned_broadcast_from_nonsigner_accepted(void)
{
mockNodeDB->addNode(REMOTE_NODE); // signer bit clear
meshtastic_MeshPacket p = makeDecoded(REMOTE_NODE, NODENUM_BROADCAST, meshtastic_PortNum_TEXT_MESSAGE_APP, SMALL_PAYLOAD);
TEST_ASSERT_EQUAL(DECODE_SUCCESS, roundTrip(&p));
TEST_ASSERT_FALSE(p.xeddsa_signed);
}
// A6: unsigned UNICAST from a known signer -> accepted (unicasts are never signed).
void test_A6_unsigned_unicast_from_signer_accepted(void)
{
mockNodeDB->addNode(REMOTE_NODE);
mockNodeDB->setSignerBit(REMOTE_NODE, true);
// Unicast to us; PRIVATE_APP avoids the unrelated legacy-DM rejection for TEXT_MESSAGE_APP.
meshtastic_MeshPacket p = makeDecoded(REMOTE_NODE, LOCAL_NODE, meshtastic_PortNum_PRIVATE_APP, SMALL_PAYLOAD);
TEST_ASSERT_EQUAL(DECODE_SUCCESS, roundTrip(&p));
}
// A7: unsigned OVERSIZED broadcast from a known signer -> accepted (couldn't have carried a sig).
void test_A7_unsigned_oversized_broadcast_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, OVERSIZED_PAYLOAD);
TEST_ASSERT_EQUAL(DECODE_SUCCESS, roundTrip(&p));
}
// A8: F2 regression - unsigned broadcast from a signer in the old "dead band": its *encoded* Data
// can't take a 64-byte signature and still fit a LoRa frame, but the old payload-size heuristic
// (payload + 64 < DATA_PAYLOAD_LEN) judged it signable and dropped it as a downgrade. Must be
// accepted: an honest signer physically cannot sign this packet.
void test_A8_unsigned_deadband_broadcast_from_signer_accepted(void)
{
mockNodeDB->addNode(REMOTE_NODE);
mockNodeDB->setSignerBit(REMOTE_NODE, true);
// Shape it like a real sender's Data: perhapsEncode adds the bitfield to packets a node
// originates, so remote broadcast traffic carries it too.
meshtastic_MeshPacket p = makeDecoded(REMOTE_NODE, NODENUM_BROADCAST, meshtastic_PortNum_TEXT_MESSAGE_APP, 167);
p.decoded.has_bitfield = true;
p.decoded.bitfield = 0;
// Pin the payload inside the dead band; if Data's encoding ever shifts, retune the payload
// size above instead of letting this test pass vacuously.
TEST_ASSERT_TRUE_MESSAGE(p.decoded.payload.size + XEDDSA_SIGNATURE_SIZE < meshtastic_Constants_DATA_PAYLOAD_LEN,
"payload must sit in the old heuristic's drop range");
TEST_ASSERT_FALSE_MESSAGE(signedEncodingFits(&p.decoded), "signed encoding must NOT fit a LoRa frame");
TEST_ASSERT_EQUAL(DECODE_SUCCESS, roundTrip(&p));
TEST_ASSERT_FALSE(p.xeddsa_signed);
}
// A9: the boundary holds - the largest broadcast whose signed encoding still fits is still
// subject to the downgrade drop when it arrives unsigned from a known signer.
// (Deliberately non-discriminating: the old heuristic dropped this packet too. A9 pins the
// boundary against over-correction; A8 and B4 are the F2 regression discriminators.)
void test_A9_unsigned_boundary_broadcast_from_signer_still_dropped(void)
{
mockNodeDB->addNode(REMOTE_NODE);
mockNodeDB->setSignerBit(REMOTE_NODE, true);
meshtastic_MeshPacket p = makeDecoded(REMOTE_NODE, NODENUM_BROADCAST, meshtastic_PortNum_TEXT_MESSAGE_APP, 166);
p.decoded.has_bitfield = true;
p.decoded.bitfield = 0;
// Exactly at the limit: signed encoding fills the frame to the last byte. Pinned so the
// boundary can't silently drift.
meshtastic_Data signedCopy = p.decoded;
signedCopy.xeddsa_signature.size = XEDDSA_SIGNATURE_SIZE;
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_POLICY_REJECT, roundTrip(&p));
}
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<uint8_t>(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);
meshtastic_MeshPacket p = makeBroadcastWithUnknownFields();
TEST_ASSERT_EQUAL_MESSAGE(DECODE_POLICY_REJECT, perhapsDecode(&p),
"unsigned broadcast from a signer must be dropped despite unknown fields");
}
void test_A19_unsigned_broadcast_from_nonsigner_with_unknown_fields_accepted(void)
{
mockNodeDB->addNode(REMOTE_NODE);
meshtastic_MeshPacket p = makeBroadcastWithUnknownFields();
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);
TEST_ASSERT_LESS_THAN_MESSAGE(rawSize, encodedDataSize(&p.decoded),
"unknown fields must drop at decode, leaving decoded size < raw");
}
// ===========================================================================
// Group B - send-side signing policy (perhapsEncode)
// ===========================================================================
// B1: our own small broadcast is auto-signed (and verifies on the way back in).
void test_B1_local_broadcast_is_signed(void)
{
uint8_t pub[32], priv[32];
crypto->generateKeyPair(pub, priv); // engine signs with this; store the matching pubkey for us
mockNodeDB->addNode(LOCAL_NODE);
mockNodeDB->setPublicKey(LOCAL_NODE, pub);
meshtastic_MeshPacket p = makeDecoded(LOCAL_NODE, NODENUM_BROADCAST, meshtastic_PortNum_TEXT_MESSAGE_APP, SMALL_PAYLOAD);
TEST_ASSERT_EQUAL(DECODE_SUCCESS, roundTrip(&p));
TEST_ASSERT_EQUAL_MESSAGE(XEDDSA_SIGNATURE_SIZE, p.decoded.xeddsa_signature.size, "broadcast should be auto-signed");
TEST_ASSERT_TRUE(p.xeddsa_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_POSITION_APP, SMALL_PAYLOAD);
TEST_ASSERT_EQUAL(DECODE_SUCCESS, roundTrip(&p));
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).
void test_B3_local_oversized_broadcast_not_signed(void)
{
meshtastic_MeshPacket p = makeDecoded(LOCAL_NODE, NODENUM_BROADCAST, meshtastic_PortNum_TEXT_MESSAGE_APP, OVERSIZED_PAYLOAD);
TEST_ASSERT_EQUAL(DECODE_SUCCESS, roundTrip(&p));
TEST_ASSERT_EQUAL_MESSAGE(0, p.decoded.xeddsa_signature.size, "oversized broadcast must not be signed");
}
// B4: F2 regression sweep - every broadcast payload size that fits a LoRa frame unsigned must
// still be deliverable: signing steps aside exactly when the signed encoding stops fitting,
// never producing TOO_LARGE (the old heuristic dead-banded payloads 167-168). Because the first
// verified packet sets our signer bit in the mock DB, the later unsigned sizes also prove the
// receiver's downgrade predicate stays exactly symmetric with the sender's sign gate.
void test_B4_all_broadcast_sizes_deliverable_no_deadband(void)
{
uint8_t pub[32], priv[32];
crypto->generateKeyPair(pub, priv);
mockNodeDB->addNode(LOCAL_NODE);
mockNodeDB->setPublicKey(LOCAL_NODE, pub);
bool sawSigned = false, sawUnsigned = false;
for (size_t n = 1; n <= 232; n++) {
char msg[32];
snprintf(msg, sizeof(msg), "payload size %u", (unsigned)n);
meshtastic_MeshPacket p = makeDecoded(LOCAL_NODE, NODENUM_BROADCAST, meshtastic_PortNum_TEXT_MESSAGE_APP, n);
TEST_ASSERT_EQUAL_MESSAGE(DECODE_SUCCESS, roundTrip(&p), msg);
// Exact oracle: signed iff the signed encoding fits the frame. signedEncodingFits() forces
// the signature size itself, so it reads the same whether or not p.decoded came back signed.
const bool isSigned = p.decoded.xeddsa_signature.size == XEDDSA_SIGNATURE_SIZE;
TEST_ASSERT_EQUAL_MESSAGE(signedEncodingFits(&p.decoded), isSigned, msg);
if (isSigned) {
TEST_ASSERT_FALSE_MESSAGE(sawUnsigned, msg); // monotonic: once too big, never signed again
TEST_ASSERT_TRUE_MESSAGE(p.xeddsa_signed, msg); // and it verified on the way back in
sawSigned = true;
} else {
sawUnsigned = true;
}
}
TEST_ASSERT_TRUE_MESSAGE(sawSigned, "sweep never produced a signed packet");
TEST_ASSERT_TRUE_MESSAGE(sawUnsigned, "sweep never crossed the fit boundary");
}
// 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_POSITION_APP, SMALL_PAYLOAD);
p.decoded.xeddsa_signature.size = XEDDSA_SIGNATURE_SIZE;
memset(p.decoded.xeddsa_signature.bytes, 0xAB, XEDDSA_SIGNATURE_SIZE);
TEST_ASSERT_EQUAL(DECODE_SUCCESS, roundTrip(&p));
TEST_ASSERT_EQUAL_MESSAGE(0, p.decoded.xeddsa_signature.size, "preset signature must be discarded on unicast");
}
// B6: the exact-fit gate tracks Data *shape*, not just payload size. A tapback-style broadcast
// (want_response + reply_id + emoji) carries extra wire bytes that shift the fit boundary; the
// sweep proves no dead band exists for that shape either, and - once the signer bit is learned -
// that the receiver's downgrade predicate stays symmetric for it too. Window
// straddles this shape's boundary; capped at 200 so even the unsigned rich encoding stays well
// inside the frame (at n=221 it first hits the pre-existing, signing-unrelated TOO_LARGE).
void test_B6_rich_shape_sweep_no_deadband(void)
{
uint8_t pub[32], priv[32];
crypto->generateKeyPair(pub, priv);
mockNodeDB->addNode(LOCAL_NODE);
mockNodeDB->setPublicKey(LOCAL_NODE, pub);
bool sawSigned = false, sawUnsigned = false;
for (size_t n = 100; n <= 200; n++) {
char msg[32];
snprintf(msg, sizeof(msg), "payload size %u", (unsigned)n);
meshtastic_MeshPacket p = makeDecoded(LOCAL_NODE, NODENUM_BROADCAST, meshtastic_PortNum_TEXT_MESSAGE_APP, n);
p.decoded.want_response = true;
p.decoded.reply_id = 0x11223344;
p.decoded.emoji = 1;
TEST_ASSERT_EQUAL_MESSAGE(DECODE_SUCCESS, roundTrip(&p), msg);
const bool isSigned = p.decoded.xeddsa_signature.size == XEDDSA_SIGNATURE_SIZE;
TEST_ASSERT_EQUAL_MESSAGE(signedEncodingFits(&p.decoded), isSigned, msg);
if (isSigned) {
TEST_ASSERT_FALSE_MESSAGE(sawUnsigned, msg);
TEST_ASSERT_TRUE_MESSAGE(p.xeddsa_signed, msg);
sawSigned = true;
} else {
sawUnsigned = true;
}
}
TEST_ASSERT_TRUE_MESSAGE(sawSigned, "rich sweep never produced a signed packet");
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 - routing pipeline and NodeInfo authentication ordering
// ===========================================================================
class NodeInfoTestShim : public NodeInfoModule
{
public:
using MeshModule::currentRequest; // allocReply() only suppresses while a request is in flight
using NodeInfoModule::allocReply;
using NodeInfoModule::handleReceivedProtobuf;
};
static meshtastic_MeshPacket makeNodeInfoPacket(bool signed_)
{
meshtastic_MeshPacket mp = makeDecoded(REMOTE_NODE, NODENUM_BROADCAST, meshtastic_PortNum_NODEINFO_APP, SMALL_PAYLOAD);
mp.xeddsa_signed = signed_;
return mp;
}
void test_N1_unsigned_nodeinfo_from_signer_dropped(void)
{
mockNodeDB->addNode(REMOTE_NODE);
mockNodeDB->setSignerBit(REMOTE_NODE, true);
NodeInfoTestShim shim;
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");
}
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(true);
meshtastic_User user = meshtastic_User_init_zero;
user.is_licensed = owner.is_licensed;
TEST_ASSERT_FALSE(shim.handleReceivedProtobuf(mp, &user));
}
void test_N3_unsigned_nodeinfo_from_nonsigner_not_dropped(void)
{
mockNodeDB->addNode(REMOTE_NODE);
NodeInfoTestShim shim;
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));
}
void test_N4_unsigned_unicast_nodeinfo_from_signer_accepted(void)
{
mockNodeDB->addNode(REMOTE_NODE);
mockNodeDB->setSignerBit(REMOTE_NODE, true);
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;
TEST_ASSERT_FALSE_MESSAGE(shim.handleReceivedProtobuf(mp, &user),
"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<int>(RoutingAuthVerdict::ACCEPT), static_cast<int>(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);
// "Far future, so no retransmission is due." Must be a representable future time, not
// UINT32_MAX: doRetransmissions() compares with an unsigned half-range test, under which
// UINT32_MAX is ~1ms in the *past* and would fire a retransmit and rewrite nextTxMsec.
const uint32_t notDueTxMsec = Time::getMillis() + 3600000UL;
pipelineRouter->addPending(prior, notDueTxMsec);
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(notDueTxMsec, 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<int>(RoutingAuthVerdict::ACCEPT), static_cast<int>(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<int>(RoutingAuthVerdict::OPAQUE_RELAY_ONLY), static_cast<int>(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<uint32_t>(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<int>(RoutingAuthVerdict::REJECT), static_cast<int>(passesRoutingAuthGate(&collision)));
TEST_ASSERT_EQUAL_MESSAGE(3, routingAuthEvaluationCount(), "same packet ID with different bytes must be reevaluated");
}
// A local reliable send that fails before reaching the radio must not outlive the error as a scheduled retransmission.
void test_C13_failed_initial_reliable_send_does_not_retry(void)
{
meshtastic_MeshPacket initial = makeDecoded(LOCAL_NODE, REMOTE_NODE, meshtastic_PortNum_ROUTING_APP, SMALL_PAYLOAD);
initial.id = 0xC13C13C1;
initial.want_ack = true;
initial.channel = MAX_NUM_CHANNELS; // Out of range, so encoding returns NO_CHANNEL.
auto *packet = packetPool.allocCopy(initial);
TEST_ASSERT_NOT_NULL(packet);
pipelineRouter->send(packet);
TEST_ASSERT_EQUAL_UINT32_MESSAGE(1, pipelineRouting->ackCalls,
"initial encoding failure must be reported to the originating client");
TEST_ASSERT_EQUAL_UINT32_MESSAGE(0, pipelineRouter->pendingCount(),
"failed initial send must not leave a retransmission pending");
pipelineRadio->failSend = true;
meshtastic_MeshPacket interfaceFailure = makeDecoded(LOCAL_NODE, REMOTE_NODE, meshtastic_PortNum_ROUTING_APP, SMALL_PAYLOAD);
interfaceFailure.id = 0xC13C13C2;
interfaceFailure.want_ack = true;
packet = packetPool.allocCopy(interfaceFailure);
TEST_ASSERT_NOT_NULL(packet);
pipelineService->sendToMesh(packet, RX_SRC_USER);
meshtastic_QueueStatus *status = pipelineService->getQueueStatusForPhone();
TEST_ASSERT_NOT_NULL(status);
TEST_ASSERT_EQUAL(ERRNO_DISABLED, status->res);
TEST_ASSERT_EQUAL_UINT32(interfaceFailure.id, status->mesh_packet_id);
pipelineService->releaseQueueStatusToPool(status);
TEST_ASSERT_EQUAL_UINT32_MESSAGE(1, pipelineRouting->ackCalls,
"interface errors are reported to the client through QueueStatus, not a routing NAK");
TEST_ASSERT_EQUAL_UINT32_MESSAGE(0, pipelineRouter->pendingCount(),
"failed interface enqueue must not leave a retransmission pending");
}
void test_C14_duty_cycle_limited_reliable_send_remains_pending(void)
{
config.lora.region = meshtastic_Config_LoRaConfig_RegionCode_EU_868;
config.lora.override_duty_cycle = false;
initRegion();
airTime->utilizationTX[0] = MS_IN_HOUR;
meshtastic_MeshPacket initial = makeDecoded(LOCAL_NODE, REMOTE_NODE, meshtastic_PortNum_ROUTING_APP, SMALL_PAYLOAD);
initial.id = 0xC14C14C1;
initial.want_ack = true;
auto *packet = packetPool.allocCopy(initial);
TEST_ASSERT_NOT_NULL(packet);
TEST_ASSERT_EQUAL(meshtastic_Routing_Error_DUTY_CYCLE_LIMIT, pipelineRouter->send(packet));
TEST_ASSERT_EQUAL_UINT32_MESSAGE(1, pipelineRouting->ackCalls,
"duty-cycle rejection must still notify the originating client");
TEST_ASSERT_EQUAL_UINT32_MESSAGE(1, pipelineRouter->pendingCount(),
"duty-cycle rejection must retain the retry for when airtime is available");
airTime->utilizationTX[0] = 0;
config.lora.region = meshtastic_Config_LoRaConfig_RegionCode_US;
initRegion();
}
// 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");
}
// ---------------------------------------------------------------------------
// N8-N11: the 12h reply-suppression window.
//
// The stamp is uptime SECONDS, not milliseconds: entries live for as long as the node stays in the
// DB, so a 32-bit millisecond stamp aliased back into the window once uptime passed 49.7 days and
// suppressed a legitimate reply for up to 12h. Driven through Time::setTestMillis() rather than by
// waiting.
// ---------------------------------------------------------------------------
static constexpr uint32_t kSuppressSecs = 12 * 60 * 60;
// Deliver a NodeInfo request from `sender` and report whether we would reply to it.
static bool wouldReplyToNodeInfoRequest(NodeInfoTestShim &shim, NodeNum sender)
{
meshtastic_MeshPacket mp = makeDecoded(sender, NODENUM_BROADCAST, meshtastic_PortNum_NODEINFO_APP, SMALL_PAYLOAD);
mp.decoded.want_response = true;
meshtastic_User user = meshtastic_User_init_zero;
user.is_licensed = owner.is_licensed;
shim.handleReceivedProtobuf(mp, &user);
NodeInfoTestShim::currentRequest = &mp;
meshtastic_MeshPacket *reply = shim.allocReply();
NodeInfoTestShim::currentRequest = nullptr;
if (reply) {
packetPool.release(reply);
return true;
}
return false;
}
// Step the injected clock the way the main loop does - advance, then publish the wrap carry.
static void advanceUptime(uint32_t deltaMs)
{
Time::advanceTestMillis(deltaMs);
Time::serviceMonotonic();
}
void test_N8_second_request_inside_the_window_is_suppressed(void)
{
mockNodeDB->addNode(REMOTE_NODE);
Time::setTestMillis(60 * 1000);
Time::serviceMonotonic();
NodeInfoTestShim shim;
TEST_ASSERT_TRUE_MESSAGE(wouldReplyToNodeInfoRequest(shim, REMOTE_NODE), "first request must be answered");
advanceUptime(60 * 60 * 1000); // 1h later, well inside the 12h window
TEST_ASSERT_FALSE_MESSAGE(wouldReplyToNodeInfoRequest(shim, REMOTE_NODE), "repeat request inside 12h must be suppressed");
}
void test_N9_request_after_the_window_is_answered(void)
{
mockNodeDB->addNode(REMOTE_NODE);
Time::setTestMillis(60 * 1000);
Time::serviceMonotonic();
NodeInfoTestShim shim;
TEST_ASSERT_TRUE(wouldReplyToNodeInfoRequest(shim, REMOTE_NODE));
advanceUptime((kSuppressSecs + 60) * 1000); // 12h + a minute
TEST_ASSERT_TRUE_MESSAGE(wouldReplyToNodeInfoRequest(shim, REMOTE_NODE), "request after 12h must be answered");
}
// The regression. A stamp is only aliased by a counter that wraps underneath it, so the failure
// needs a *full* 2^32 ms of uptime to elapse, not merely a crossing of the boundary: with 32-bit
// millisecond stamps `now - stamp` then computes as 0 and the sender looks like it was answered
// this instant. Uptime seconds do not wrap for 136 years, so the entry reads as ~49.7 days old.
void test_N10_stale_stamp_does_not_alias_after_a_full_wrap(void)
{
mockNodeDB->addNode(REMOTE_NODE);
Time::setTestMillis(0x80000000u); // ~24.8 days of uptime
Time::serviceMonotonic();
NodeInfoTestShim shim;
TEST_ASSERT_TRUE(wouldReplyToNodeInfoRequest(shim, REMOTE_NODE));
// A whole millis() cycle, in two serviced halves - one publish per window is the contract.
advanceUptime(0x80000000u);
advanceUptime(0x80000000u);
TEST_ASSERT_TRUE_MESSAGE(wouldReplyToNodeInfoRequest(shim, REMOTE_NODE),
"a stamp one full wrap old must read as ~49.7 days, not as this instant");
}
// Suppression must still behave normally either side of the boundary: still suppressing inside the
// window, and answering again once 12h have passed, with the stamp and the reading on opposite
// sides of the wrap.
void test_N11_window_still_applies_across_the_wrap(void)
{
mockNodeDB->addNode(REMOTE_NODE);
Time::setTestMillis(0xFFFF0000u); // just short of the wrap
Time::serviceMonotonic();
NodeInfoTestShim shim;
TEST_ASSERT_TRUE(wouldReplyToNodeInfoRequest(shim, REMOTE_NODE));
advanceUptime(0x20000u); // ~131s later, and now past the wrap
TEST_ASSERT_FALSE_MESSAGE(wouldReplyToNodeInfoRequest(shim, REMOTE_NODE),
"the window must still bite when the stamp sits the other side of the wrap");
advanceUptime((kSuppressSecs + 60) * 1000);
TEST_ASSERT_TRUE_MESSAGE(wouldReplyToNodeInfoRequest(shim, REMOTE_NODE),
"and must still release once 12h have passed across the wrap");
}
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);
}
// ===========================================================================
// Group D - encoding invariants the routing gates depend on
// ===========================================================================
// D1: the encoded overhead of the signature field must be exactly XEDDSA_SIGNATURE_FIELD_BYTES
// (1 tag byte + 1 length byte + 64 signature bytes). The receiver downgrade predicate adds this
// constant to the unsigned size; this test pins that it matches the real wire overhead the
// sender's encoder produces, keeping the two sides symmetric. It drifts if the field number ever
// moves to >= 16 or the signature grows past 127 bytes.
void test_D1_signature_field_overhead_exact(void)
{
meshtastic_Data d = meshtastic_Data_init_zero;
d.portnum = meshtastic_PortNum_TEXT_MESSAGE_APP;
d.payload.size = 100;
const size_t without = encodedDataSize(&d);
d.xeddsa_signature.size = XEDDSA_SIGNATURE_SIZE;
const size_t with = encodedDataSize(&d);
TEST_ASSERT_EQUAL_MESSAGE(XEDDSA_SIGNATURE_FIELD_BYTES, with - without, "signature field wire overhead drifted");
}
// ===========================================================================
// Group E - decoded-ingress policy (checkXeddsaReceivePolicy)
// ===========================================================================
// Already-decoded packets never reach perhapsDecode's crypto path (it early-returns), so
// plaintext-MQTT downlink applies this policy function directly at ingress (MQTT.cpp). These
// tests drive it the same way: decoded packets, sized from p->decoded exactly as the RF path is.
// End-to-end MQTT wiring is covered in test_mqtt.
// E1: unsigned small broadcast from a known signer -> dropped (downgrade protection holds on
// the decoded-ingress path too - the F3 bypass).
void test_E1_decoded_unsigned_broadcast_from_signer_dropped(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);
TEST_ASSERT_FALSE(checkXeddsaReceivePolicy(&p));
}
// E2: unsigned broadcast from a non-signer -> accepted.
void test_E2_decoded_unsigned_broadcast_from_nonsigner_accepted(void)
{
mockNodeDB->addNode(REMOTE_NODE); // signer bit clear
meshtastic_MeshPacket p = makeDecoded(REMOTE_NODE, NODENUM_BROADCAST, meshtastic_PortNum_TEXT_MESSAGE_APP, SMALL_PAYLOAD);
TEST_ASSERT_TRUE(checkXeddsaReceivePolicy(&p));
TEST_ASSERT_FALSE(p.xeddsa_signed);
}
// E3: valid signature with a known key -> accepted, marked verified, signer bit learned.
void test_E3_decoded_valid_signature_verified_and_learns_signer(void)
{
uint8_t pub[32], priv[32];
crypto->generateKeyPair(pub, priv);
mockNodeDB->addNode(REMOTE_NODE);
mockNodeDB->setPublicKey(REMOTE_NODE, pub);
meshtastic_MeshPacket p = makeDecoded(REMOTE_NODE, NODENUM_BROADCAST, meshtastic_PortNum_TEXT_MESSAGE_APP, SMALL_PAYLOAD);
signWithCurrentKey(&p);
TEST_ASSERT_TRUE(checkXeddsaReceivePolicy(&p));
TEST_ASSERT_TRUE(p.xeddsa_signed);
TEST_ASSERT_TRUE_MESSAGE(remoteSignerBit(), "verified signature must set the signer bit");
}
// E4: corrupted signature with a known key -> dropped.
void test_E4_decoded_bad_signature_dropped(void)
{
uint8_t pub[32], priv[32];
crypto->generateKeyPair(pub, priv);
mockNodeDB->addNode(REMOTE_NODE);
mockNodeDB->setPublicKey(REMOTE_NODE, pub);
meshtastic_MeshPacket p = makeDecoded(REMOTE_NODE, NODENUM_BROADCAST, meshtastic_PortNum_TEXT_MESSAGE_APP, SMALL_PAYLOAD);
signWithCurrentKey(&p);
p.decoded.xeddsa_signature.bytes[0] ^= 0xFF;
TEST_ASSERT_FALSE(checkXeddsaReceivePolicy(&p));
TEST_ASSERT_FALSE(p.xeddsa_signed);
}
// E5: unsigned oversized broadcast from a signer -> accepted (packets whose signed encoding
// wouldn't fit are exempt, identically to the RF path: both size p->decoded).
void test_E5_decoded_unsigned_oversized_broadcast_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, OVERSIZED_PAYLOAD);
TEST_ASSERT_TRUE(checkXeddsaReceivePolicy(&p));
}
// 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);
mockNodeDB->setSignerBit(REMOTE_NODE, true);
meshtastic_MeshPacket p = makeDecoded(REMOTE_NODE, LOCAL_NODE, meshtastic_PortNum_PRIVATE_APP, SMALL_PAYLOAD);
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
// impersonation of signer REMOTE. The malformed-size reject drops it before that math runs.
void test_E8_decoded_partial_signature_from_signer_dropped(void)
{
mockNodeDB->addNode(REMOTE_NODE);
mockNodeDB->setSignerBit(REMOTE_NODE, true);
// 146-byte payload sits in the band that WOULD fit a signature (so an honest unsigned one is a
// downgrade), but the 63 bogus signature bytes push the raw size over the frame limit.
meshtastic_MeshPacket p = makeDecoded(REMOTE_NODE, NODENUM_BROADCAST, meshtastic_PortNum_TEXT_MESSAGE_APP, 146);
p.decoded.xeddsa_signature.size = XEDDSA_SIGNATURE_SIZE - 1;
memset(p.decoded.xeddsa_signature.bytes, 0xCD, p.decoded.xeddsa_signature.size);
TEST_ASSERT_FALSE_MESSAGE(checkXeddsaReceivePolicy(&p), "partial signature from a signer must be dropped");
TEST_ASSERT_FALSE(p.xeddsa_signed);
}
// E9: the malformed-size reject is unconditional - a partial signature is dropped even from a
// node we've never seen sign (an honest sender never emits a 1..63-byte signature field).
void test_E9_decoded_partial_signature_from_nonsigner_dropped(void)
{
mockNodeDB->addNode(REMOTE_NODE); // signer bit clear
meshtastic_MeshPacket p = makeDecoded(REMOTE_NODE, NODENUM_BROADCAST, meshtastic_PortNum_TEXT_MESSAGE_APP, SMALL_PAYLOAD);
p.decoded.xeddsa_signature.size = 10;
memset(p.decoded.xeddsa_signature.bytes, 0x5A, p.decoded.xeddsa_signature.size);
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<AuthPipelineRadio>();
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");
RUN_TEST(test_A1_valid_signature_accepted_and_learns_signer);
RUN_TEST(test_A2_bad_signature_dropped);
RUN_TEST(test_A3_signed_no_pubkey_accepted_unverified);
RUN_TEST(test_A4_downgrade_unsigned_broadcast_from_signer_dropped);
RUN_TEST(test_A5_unsigned_broadcast_from_nonsigner_accepted);
RUN_TEST(test_A6_unsigned_unicast_from_signer_accepted);
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_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);
RUN_TEST(test_B2_local_unicast_not_signed);
RUN_TEST(test_B3_local_oversized_broadcast_not_signed);
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: 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);
RUN_TEST(test_C13_failed_initial_reliable_send_does_not_retry);
RUN_TEST(test_C14_duty_cycle_limited_reliable_send_remains_pending);
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);
RUN_TEST(test_N8_second_request_inside_the_window_is_suppressed);
RUN_TEST(test_N9_request_after_the_window_is_answered);
RUN_TEST(test_N10_stale_stamp_does_not_alias_after_a_full_wrap);
RUN_TEST(test_N11_window_still_applies_across_the_wrap);
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);
printf("\n=== Group E: decoded-ingress policy ===\n");
RUN_TEST(test_E1_decoded_unsigned_broadcast_from_signer_dropped);
RUN_TEST(test_E2_decoded_unsigned_broadcast_from_nonsigner_accepted);
RUN_TEST(test_E3_decoded_valid_signature_verified_and_learns_signer);
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_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);
const int result = UNITY_END();
airTime = savedAirTime;
nodeStatus = savedNodeStatus;
exit(result);
}
void loop() {}
#else // XEdDSA or PKI excluded
void setUp(void) {}
void tearDown(void) {}
void setup()
{
initializeTestEnvironment();
UNITY_BEGIN();
exit(UNITY_END());
}
void loop() {}
#endif