Skip to content

fix(kerykeion): attribute mesh source to the verified sender, not the packet's claim - #381

Open
forkwright wants to merge 5 commits into
mainfrom
fix/246-mesh-source-attribution
Open

fix(kerykeion): attribute mesh source to the verified sender, not the packet's claim#381
forkwright wants to merge 5 commits into
mainfrom
fix/246-mesh-source-attribution

Conversation

@forkwright

@forkwright forkwright commented Aug 17, 2026

Copy link
Copy Markdown
Owner

Summary

One defect in two places: mesh-packet attribution trusted a field the sender controls, without verifying it against anything the receiving side actually observed.

Fix shape: attribute to what the receiving side can verify (the packet's actual sender), not to what the packet claims; where no stronger signal exists at that layer, make the unverified attribution visibly unverified in the type rather than a plain field that reads as fact.

Changes

#246crates/kerykeion/src/collector.rs:75 adds ClaimedNodeNum, a private newtype wrapping the raw wire from value. ClaimedNodeNum::from_wire (collector.rs:82) rejects the two non-node sentinels; accept_unauthenticated (collector.rs:94) is the one, explicitly-named place a raw wire claim becomes a NodeNum used for DB attribution — the name states the trust decision instead of a bare cast reading as fact. handle_mesh_packet (collector.rs:196) now drops a packet claiming a sentinel from before it reaches the node DB.

node_came_online (router.rs:255) is the mechanism the issue names as reachable "off raw packet attribution" (a spoofed from redirecting a store-and-forward flush toward an attacker-named destination). Repo-wide grep confirms it is not currently wired to the packet-receive path at all — the only callers are MeshRouter's own tests (router.rs:444, 506). Documented the invariant at router.rs:256 (WARNING tag) so a future wiring attempt is warned rather than silent.

#207processor.rs:120 now passes from (the packet's actual sender) into handle_neighborinfo. handle_neighborinfo (processor.rs:383) computes claimed = NodeNum(ni.node_id) (processor.rs:392) and requires claimed == from (processor.rs:393); on mismatch the report is dropped (logged, no topology mutation, no event) rather than silently reattributed to from — a mismatch doesn't distinguish a forged claim from a payload genuinely describing a different node, so neither identity is trustworthy for that report. On agreement, topology mutations use from throughout.

Updated the two doc comments (processor.rs:140, signals.rs:91) that cited NEIGHBORINFO as the reason an event's subject can differ from the packet sender — that case is now enforced equal by construction. TRACEROUTE's intermediate hops are the still-live example.

#247message.rs:65/message.rs:75: position/position_with_config now return Result<Self, Error> and validate lat/lon (message.rs:88) — finite, and within [-90, 90]/[-180, 180] — before the as i32 cast, returning the new Error::InvalidPosition (error.rs:241) on failure. Dropped #[must_use] on both (Result already carries it; an explicit one is clippy::double_must_use under -D warnings, kanon#3473). Added #[derive(Debug)] to MessageBuilder so the new tests can format the rejected Result.

Done when: (from the issues)

#246

  • "a packet with from == 0 or from == 0xFFFF_FFFF neither creates nor updates a node DB entry" — collector.rs:196 (ClaimedNodeNum::from_wire guard); proven by collector_tests_attribution.rs:41 and :71.
  • "node_came_online fires only from an authenticated reachability event rather than raw from attribution" — already true on main (verified: zero non-test call sites); now documented as an invariant at router.rs:256 so it stays true.

#207

  • "NeighborInfo-driven topology mutations use a sender-correlated reporter identity" — processor.rs:392-393 (claimed == from gate; topology writes use from).
  • "a payload node_id that disagrees with the packet source is rejected or flagged" — rejected: early return at processor.rs:394-399.
  • "a test asserting a spoofed-node_id frame does not create links attributed to the victim node" — processor_tests.rs:659.

#247

  • "passing f64::NAN as lat returns an Err from the position builder rather than producing a packet" — message.rs:88-93; proven by message.rs:348.

Negative fixtures

Verified on the build box (verda-build), against the exact shipped code — not a re-implementation. All three fixtures were run against unmodified origin/main (bebbcd1) first and observed to fail/demonstrate the defect, then run again against this branch's tip and observed to pass.

Negative fixture: crates/kerykeion/src/collector_tests_attribution.rs (process_packet_ignores_zero_from_sentinel, process_packet_ignores_broadcast_from_sentinel) — watched failing by cargo test -p kerykeion sentinel -- --nocapture against unmodified origin/main, which produced:

thread '...process_packet_ignores_broadcast_from_sentinel' panicked at collector_tests_attribution_prefix.rs:65:5:
from == broadcast must never create a node-DB entry
thread '...process_packet_ignores_zero_from_sentinel' panicked at collector_tests_attribution_prefix.rs:38:5:
from == 0 must never create a node-DB entry
test result: FAILED. 1 passed; 2 failed

and passes after (test result: ok. 285 passed; 0 failed, this branch's tip).

Negative fixture: crates/kerykeion/src/processor_tests.rs:659 (neighborinfo_spoofed_node_id_is_dropped_not_attributed_to_the_victim) — watched failing by cargo test -p kerykeion neighborinfo -- --nocapture against unmodified origin/main, which produced:

thread '...neighborinfo_spoofed_node_id_is_dropped_not_attributed_to_the_victim' panicked at processor_tests.rs:690:5:
a spoofed node_id must yield no events, got [TopologyChange { from: NodeNum(8738), to: NodeNum(13107), snr: 4.0 }]

(NodeNum(8738) = 0x2222, the victim/claimed node_id in the fixture — the packet's real sender was 0x1111/4369.) Passes after (same run as above, 285/285).

Negative fixture (#247): the fix changes position's signature from infallible (-> Self) to fallible (-> Result<Self, Error>), so the shipped post-fix test (message.rs:348, position_rejects_nan_latitude) cannot compile against pre-fix code — there is no Error::InvalidPosition to observe yet. Ran a probe instead, calling the actual pre-fix shipped API verbatim (MessageBuilder::position(DEST, f64::NAN, -122.4194).build(FROM_NODE, &[]).unwrap(), against unmodified origin/main) and asserting on the resulting wire value: it succeeded and produced latitude_i == 0 — the silent corruption the issue describes, demonstrated against shipped code. Post-fix, the real fixture (message.rs:348) asserts Err(Error::InvalidPosition) and passes; message.rs:380 (position_accepts_boundary_coordinates) is the falsifiable half, confirming the guard doesn't just reject everything.

Testing

  • cargo fmt --check passes
  • cargo clippy -D warnings passes
  • cargo test passes
  • Manual testing completed (negative fixtures below, run against shipped code on the build box)

Verification

  • cargo fmt -p kerykeion — clean (build box + locally).
  • kanon lint <changed files> --all — clean except two pre-existing, unrelated findings, both confirmed outside this diff's hunks: RUST/no-arc-mutex-anti-pattern at collector.rs:296/298/381 (unrelated functions, already on main) and RUST/file-too-long on collector_tests.rs (807 lines on main, unchanged by this PR — the new Unauthenticated mesh source attribution lets any node spoof any NodeNum in the node DB #246 tests went into a new sibling file, collector_tests_attribution.rs, specifically so this PR doesn't make that pre-existing overage worse).
  • cargo clippy -p kerykeion --all-targets -- -D warnings — clean on the build box.
  • cargo test -p kerykeion — 285/285 passing on the build box, including all new tests.
  • cargo fmt -p kerykeion --check and cargo test/cargo clippy --workspace (full workspace, not just kerykeion) — clean, 987/987 tests passing.

No Gate-Passed stamp on this PR — explained, not silently missing. ~/gate-repo.sh akroasis <branch> (kanon gate --tier full --stamp) fails at its kanon lint stage on 2 pre-existing OIKOS/private-content errors in CONTRIBUTING.md:8/:18 (an internal kanon.lan forge hostname leaked in a public doc). This is unrelated to kerykeion/this diff, confirmed present on unmodified origin/main, and already tracked as #377 (filed by a sibling agent gating a different PR in this same wave — its own text: "no PR can currently produce a clean local Gate-Passed trailer regardless of what it touches"). Fixing #377 is out of scope for this PR (a security-attribution fix touching CONTRIBUTING.md would blur the diff for no reason connected to #246/#207/#247), so this PR was verified instead by running the actual gate stages directly against the shipped branch on the build box (listed above) — fmt, clippy -D warnings, and the full test suite, all green, all against real compiled+executed code rather than the stamp mechanism.

Independent confirmation: real GitHub Actions CI (gate / full-gate-build) ran on this PR and passedfmt / check / clippy / nextest / doctest, 4m18s, green. Absence of a Gate-Passed trailer makes the hybrid-gate workflow (#262's partial fix, landed as #288) fall through to a real hosted build rather than trusting a stamp, so this is genuine independent verification, not the "checks that fail instantly with steps=0" no-op pattern. cargo-audit / cargo-deny / osv-scanner / ai-attribution / check-trailer all pass too. #262 (no-CI) itself stays open for its documented residual gaps (dependabot bypass, no push-to-main check) — unrelated to this PR passing its own checks.

Closes #246
Closes #207
Closes #247

forkwright added 5 commits August 16, 2026 22:02
…tinel from values

mesh_packet.from feeds the node DB unconditionally, so any node on the
mesh can claim any NodeNum -- including the two values that are never a
real originating node (0, and the 0xFFFF_FFFF broadcast address) -- and
create or update that entry.

Meshtastic carries no cryptographic sender binding at this layer in this
proto subset (no signature, no relay_node field), so `from` remains the
strongest identity signal available but not a verified fact. Wrap it in
ClaimedNodeNum at the one place it turns into a node-DB attribution, so
the conversion reads as a stated trust decision rather than a bare cast,
and reject the two non-node sentinels before they reach the DB.

node_came_online (router.rs) moves store-and-forward messages toward a
NodeNum and was flagged as reachable from raw packet attribution; it is
not -- verified by repo-wide grep, only MeshRouter's own tests call it.
Documented the invariant so it stays that way.
…t the payload claim

handle_neighborinfo derived the reporting node's identity entirely from
the protobuf body field ni.node_id, never consulting the packet's actual
transmitting sender. Any mesh node could broadcast a NEIGHBORINFO_APP
packet claiming an arbitrary node_id and have the resulting topology
links, and the located signal, attributed to that victim rather than to
itself.

Pass the packet's sender into handle_neighborinfo and require
ni.node_id == from; on mismatch the report is dropped rather than
silently reattributed to the sender, since a mismatch does not
distinguish a forged claim from a payload describing a genuinely
different node.

Updated the two doc comments that cited NEIGHBORINFO as the reason event
subjects can differ from the packet sender -- that case is now enforced
equal by construction. TRACEROUTE's intermediate hops are the still-live
example and now stand alone.
position_with_config converted caller-supplied f64 lat/lon straight to
i32 via an as cast with no finiteness or range check. Rust saturates an
out-of-range as-cast rather than panicking, so a NaN latitude (e.g. from
a failed GPS read) silently became 0 and an infinite value silently
became an i32 extreme, producing a plausible-looking wire coordinate
instead of an error -- exactly the silent corruption the removed SAFETY
comment claimed could not happen.

Validate finiteness and Meshtastic's geographic range before the cast
and return Error::InvalidPosition on failure. position and
position_with_config now return Result<Self, Error>; dropped their
#[must_use] since Result already carries it at the type level
(clippy::double_must_use under -D warnings, kanon#3473).
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

1 participant