data: check MeshCore RX-log adverts against their signatures (#996)

This commit is contained in:
Afri authored and GitHub committed 2026-10-08 00:33:48 +02:00
1 parent 3a07231e6e
commit 5e0deb8a46
11 files changed
+1834 -74

No files matched your search

+2 -2
View File
@@ -167,9 +167,9 @@ MeshCore advert sourcing (capturing adverts from other nodes). A MeshCore node a
- *Auto-update re-fetch (freshness).* The provider sets `mc.auto_update_contacts = True`, so the meshcore library re-fetches changed contacts (incrementally, by `lastmod`) whenever an `ADVERTISEMENT` / `PATH_UPDATE` push arrives. A re-advert from a known node therefore refreshes its `last_advert` / position without waiting for a reconnect.
- *Bare advert (reach).* The `ADVERTISEMENT` (pubkey-only) push is also handled directly: for a public key not in the contact roster it upserts a minimal "heard now" node (`lastHeard`, `protocol`, `user.shortName`/`publicKey` only - no name/type/position), so radios running with auto-add off still register the advertiser. Known keys are skipped (the auto-update path keeps them fresh). The Ruby web app preserves an existing long name on conflict, so this placeholder never clobbers a richer record, and a later full contact advertisement reconciles it. Reconciliation does not depend on timestamp ordering: the contact record carries `lastHeard = last_advert` (the sender-stamped advert-creation time), which is always older than the placeholder's wall-clock stamp - the web app's node upsert therefore fills identity fields (name, role, public key, …) that are still NULL even from an older-stamped record, while timestamps/telemetry stay freshness-guarded (ACCEPTANCE GH-A1). A record under another key than the row's fills nothing (SPEC NI2).
- *RX-log advert (full identity + signal, roster-independent - SPEC RF3).* Companion firmware ≥ 1.16 pushes every received RF frame (`RX_LOG_DATA`) while a client is connected; the library parses `ADVERT` frames completely (full public key, name, type, optional lat/lon). The ingestor converts these to full node upserts carrying per-reception `snr` / `rssi` / `hopsAway`, so node identity and signal metrics no longer depend on the radio's contact roster at all - including when the roster is full. Absent RX-log frames (older/other builds) are never an error; the three paths above still function. Non-`ADVERT` RX-log frames are not ingested: decrypted `GRP_TXT` copies only route channel messages (see `POST /api/messages`), and the DEBUG-only capture drops their decrypted text.
- *RX-log advert (full identity + signal, roster-independent - SPEC RF3).* Companion firmware ≥ 1.16 pushes every received RF frame (`RX_LOG_DATA`) while a client is connected; the library parses `ADVERT` frames completely (full public key, name, type, optional lat/lon). The radio pushes each frame before the firmware checks it, and the library does not check the advert's signature, so the ingestor does (SPEC SG1): it posts an RX-log advert, node and position, only when the Ed25519 signature in the raw payload (`pkt_payload`) verifies under the advert's own key over `pub_key + timestamp + app_data`, the bytes `Mesh::onRecvPacket` checks. It also refuses app data longer than the 32 bytes the firmware signs, a parsed `adv_key` that is not the key in the signed bytes, and a key of small order, which no private key stands behind (SPEC SG4). Like the firmware's roster, it then posts an advert only when its signed timestamp is newer than the newest one accepted for that key, or reported by a roster contact as its `last_advert` (SPEC SG5): a replay, or the same advert heard again over another path, posts nothing. That memory is per process and mirrors the radio's roster. The first `CONTACTS` listing of a connection, the whole roster, sets which keys are roster keys; a later listing, the auto-update re-fetch of changed contacts, adds the contacts it names; `NEW_CONTACT`, which the radio pushes for an advert from a key it did not add, and `NEXT_CONTACT` only raise a roster key's timestamp. Adverts from new keys cannot push a roster key out. A key the next full listing lacks joins the other keys, of which the memory keeps the 4096 most recently accepted. A key the radio adds is protected once a listing names it, and if auto-add lets the radio's own roster be flooded, the protection is the radio's: a key stays protected while it is in the radio's roster. After a restart a key outside the roster passes its first advert, and roster keys are protected again once the connection's first listing has run. A refused advert posts nothing and logs one debug line (`context=meshcore.rx_advert`, with `node_id` and `reason`). The three paths above are not checked again: the firmware verified each advert they carry before it added the contact or pushed `ADVERTISEMENT`, and a contact added over the companion link itself is the operator's own. The ingestor converts verified adverts to full node upserts carrying the `snr` / `rssi` / `hopsAway` of each advert's first reception, so node identity and signal metrics no longer depend on the radio's contact roster at all - including when the roster is full. Absent RX-log frames (older/other builds) are never an error; the three paths above still function. Non-`ADVERT` RX-log frames are not ingested: decrypted `GRP_TXT` copies only route channel messages (see `POST /api/messages`), and the DEBUG-only capture drops their decrypted text.
*Position anchoring (SPEC MR5).* One advert reaches the radio several times over different flood paths, each copy carrying its own receiver-side `recv_time`. The position derived from an RX-log advert is therefore keyed on the advert's sender-side `adv_timestamp` - both for the `POST /api/positions` record and for the node row's `position.time` - falling back to `recv_time` only when the parser reported no usable value. `lastHeard` stays receiver-side. Because `_store_meshcore_position` derives its row id from `(node_id, position_time)`, every copy of one advert - across flood paths and across co-operating ingestors - collapses to a single position row. New protocols whose position data rides on rebroadcast beacons SHOULD likewise anchor on a sender-side timestamp.
*Position anchoring (SPEC MR5).* One advert reaches the radio several times over different flood paths, each copy carrying its own receiver-side `recv_time`. The position derived from an RX-log advert is therefore keyed on the advert's sender-side `adv_timestamp` - both for the `POST /api/positions` record and for the node row's `position.time` - falling back to `recv_time` only when the parser reported no usable value. `lastHeard` stays receiver-side. Because `_store_meshcore_position` derives its row id from `(node_id, position_time)`, every copy of one advert - across flood paths and across co-operating ingestors - collapses to a single position row. One ingestor posts only the first copy it hears (SPEC SG5); the anchor collapses the copies of co-operating ingestors. New protocols whose position data rides on rebroadcast beacons SHOULD likewise anchor on a sender-side timestamp.
*Key binding (SPEC NI3).* An advert, from any of the four paths, whose full public key differs from the key the row is bound to refreshes only `last_heard`, telemetry and the signal fields; the names, role, key and position stay those of the first key (see "Collision trade-off" above).
@@ -0,0 +1,216 @@
# Copyright © 2025-26 l5yth & contributors
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""Refuse a MeshCore RX-log advert that is not newer than the last one accepted.
The firmware's roster refuses an advert whose signed timestamp is not newer
than the last one it accepted from that contact (``BaseChatMesh::onAdvertRecv``,
``src/helpers/BaseChatMesh.cpp:124``). The RX log carries every received
copy of every advert, replays included, so the ingestor applies the same rule
to it (SPEC SG5): per process and per public key it remembers the newest
signed timestamp it accepted, or that the radio's roster reported as a
contact's ``last_advert``, and posts an RX-log advert only when the advert's
own signed timestamp is strictly newer. A replay, and the same advert heard
again over another path, post nothing.
The memory has two pools. The roster pool mirrors the radio's roster, and
only the radio's ``CONTACTS`` listings change who is in it
(:func:`_take_roster_listing`): the first listing of a connection is the whole
roster and replaces the membership, and a later listing (the library's
auto-update re-fetch, which asks only for contacts changed since the newest
``lastmod`` it saw) adds the contacts it names. ``NEW_CONTACT`` and
``NEXT_CONTACT`` only raise a member's timestamp (:func:`_raise_roster_advert`):
the radio pushes ``NEW_CONTACT`` for an advert from a key it did not add, so a
flood of new keys never enters the roster pool through it; a key the radio
auto-adds enters through the next listing, as a spare the cap evicts first.
Every other key sits in the
RX-log pool of :data:`_REPLAY_MEMORY_CAP` keys, which forgets the least
recently accepted first.
Limits: a key the radio adds between listings is protected once the next
listing names it, and if auto-add lets the radio's own roster be flooded, the
protection is the radio's: a key stays protected while it is in the radio's
roster. A restart empties both pools until the connection's first listing
refills the roster pool; after that only a key known solely from the RX log
passes its first advert again.
"""
from __future__ import annotations
import threading
from collections import OrderedDict
_REPLAY_MEMORY_CAP = 4096
"""Most keys the RX-log pool remembers, least recently accepted forgotten
first.
More than ten times the largest companion roster (``MAX_CONTACTS=350`` in the
firmware's variants), at about 860 KiB when full."""
_ROSTER_MEMORY_CAP = 1024
"""Backstop cap on the roster pool, which the radio's own table bounds.
About three times the largest companion roster. Past it the pool hands the
least recently refreshed key that the latest full listing lacks to the RX-log
pool; it never evicts a key of the latest full listing."""
_heard: OrderedDict[str, int] = OrderedDict()
"""Newest accepted signed timestamp per lowercase hex key outside the radio's
roster, least recently accepted first."""
_roster: OrderedDict[str, int] = OrderedDict()
"""Newest signed timestamp per lowercase hex key of the radio's roster, least
recently refreshed first."""
_listed: set[str] = set()
"""Keys of the latest full listing: the ones :data:`_ROSTER_MEMORY_CAP` never
evicts."""
_lock = threading.Lock()
"""Serialises the memory: a reconnect starts a new MeshCore event-loop thread
while the old one may still be finishing its last events."""
def _store(
pool: OrderedDict[str, int], cap: int, pub_key_hex: str, timestamp: int
) -> None:
"""Store *timestamp* as the newest for *pub_key_hex* in *pool*, cap it.
The caller holds :data:`_lock`.
Parameters:
pool: :data:`_heard` or :data:`_roster`.
cap: Most keys *pool* keeps; the least recent beyond it are forgotten.
pub_key_hex: Lowercase hex public key, made the most recent.
timestamp: Signed advert timestamp to remember.
"""
pool[pub_key_hex] = timestamp
pool.move_to_end(pub_key_hex)
while len(pool) > cap:
pool.popitem(last=False)
def _leave_roster(pub_key_hex: str) -> None:
"""Move a key out of the roster pool into the RX-log pool, timestamp kept.
It becomes an ordinary RX-log-pool entry, as if just accepted, so a
replay of its last advert is still refused until that pool forgets it.
The caller holds :data:`_lock`.
Parameters:
pub_key_hex: A key of :data:`_roster`.
"""
_store(_heard, _REPLAY_MEMORY_CAP, pub_key_hex, _roster.pop(pub_key_hex))
def _accept_advert_timestamp(pub_key_hex: str, timestamp: int) -> bool:
"""Accept a verified RX-log advert's timestamp when it is strictly newer.
Call it only for an advert whose signature holds, so that no forged
timestamp is ever remembered: a forged far-future one would otherwise
lock the genuine node out. The key stays in the pool it is in.
Parameters:
pub_key_hex: The advert's public key as lowercase hex (``adv_key``).
timestamp: The advert's signed sender-side timestamp.
Returns:
``True`` when the timestamp is newer than any remembered for the key,
which then becomes the newest; ``False`` for a replay or a repeat.
"""
with _lock:
if pub_key_hex in _roster:
pool, cap = _roster, _ROSTER_MEMORY_CAP
else:
pool, cap = _heard, _REPLAY_MEMORY_CAP
newest = pool.get(pub_key_hex)
if newest is not None and timestamp <= newest:
return False
_store(pool, cap, pub_key_hex, timestamp)
return True
def _take_roster_listing(last_adverts: dict[str, object], *, full: bool) -> None:
"""Make the roster pool follow one ``CONTACTS`` listing from the radio.
The firmware verified the advert each listed contact carries, and its
``last_advert`` is that advert's signed sender-side timestamp
(``ContactInfo::last_advert_timestamp``), the clock RX-log adverts carry.
Each listed key joins or stays in the roster pool with the newest of its
``last_advert`` and what was remembered for it, never lowered.
A full listing (*full*: the first of a connection, asked with ``since = 0``)
is the whole roster: its keys replace the membership, and a member it
lacks leaves for the RX-log pool (:func:`_leave_roster`). A later listing
(the auto-update re-fetch) names only contacts changed since the newest
``lastmod`` the library saw, so a member it lacks is unchanged and stays.
Parameters:
last_adverts: Each listed contact's hex public key and ``last_advert``;
a contact whose ``last_advert`` is not an ``int`` is skipped.
full: Whether the listing is the radio's whole roster.
"""
listed = {
key.lower(): stamp
for key, stamp in last_adverts.items()
if isinstance(stamp, int)
}
with _lock:
if full:
for key in [key for key in _roster if key not in listed]:
_leave_roster(key)
_listed.clear()
_listed.update(listed)
for key, stamp in listed.items():
newest = max(stamp, _roster.get(key, stamp), _heard.pop(key, stamp))
_roster[key] = newest
_roster.move_to_end(key)
# The radio's table bounds the pool; the cap only backs it up, and it
# never evicts a key of the latest full listing.
spare = [key for key in _roster if key not in _listed]
while len(_roster) > _ROSTER_MEMORY_CAP and spare:
_leave_roster(spare.pop(0))
def _raise_roster_advert(pub_key_hex: str, last_advert: object) -> None:
"""Raise a roster member's timestamp to a contact event's ``last_advert``.
``NEW_CONTACT`` and ``NEXT_CONTACT`` call this. It never adds a key: the
radio pushes ``NEW_CONTACT`` for an advert from a key it did not add to
its roster (``BaseChatMesh::onAdvertRecv``, ``MyMesh::onDiscoveredContact``),
so only a ``CONTACTS`` listing makes a key a member.
Parameters:
pub_key_hex: The contact's public key as hex.
last_advert: The contact's ``last_advert``; anything but an ``int`` is
ignored, as is a value not newer than the remembered one.
"""
if not isinstance(last_advert, int):
return
key = pub_key_hex.lower()
with _lock:
if key in _roster and last_advert > _roster[key]:
_roster[key] = last_advert
_roster.move_to_end(key)
def _reset_replay_memory() -> None:
"""Forget every remembered timestamp and the roster, as a restart does.
Provided for test isolation.
"""
with _lock:
_heard.clear()
_roster.clear()
_listed.clear()
@@ -0,0 +1,156 @@
# Copyright © 2025-26 l5yth & contributors
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""Check the Ed25519 signature of a MeshCore advert heard in the RX log.
The companion radio pushes every frame it receives to the RX log before the
firmware checks it (``Dispatcher::checkRecv`` calls ``logRxRaw`` before it
parses the packet), and the ``meshcore`` library's parser extracts an
advert's signature without checking it. The firmware verifies an advert only
on its way into the contact roster (``Mesh::onRecvPacket``), so roster
contacts and ``ADVERTISEMENT`` pushes arrive verified and an RX-log advert
does not. :func:`_rx_advert_signature_problem` repeats the firmware's check
on the raw payload, so the ingestor posts an RX-log advert only when its
signature holds (SPEC SG1) under a key that is not of small order (SG4).
:func:`_rx_advert_signed_timestamp` reads the signed timestamp the replay
check compares (SG5, :mod:`.advert_replay`).
"""
from __future__ import annotations
from cryptography.exceptions import InvalidSignature
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey
_PUB_KEY_SIZE = 32
"""Bytes of the advertiser's Ed25519 public key (firmware ``PUB_KEY_SIZE``)."""
_SIGNATURE_OFFSET = _PUB_KEY_SIZE + 4
"""Offset of the signature in an ``ADVERT`` payload: after the public key and
the 4-byte sender-side timestamp."""
_APP_DATA_OFFSET = _SIGNATURE_OFFSET + 64
"""Offset of the app data (flags, position, name): after the 64-byte
signature (firmware ``SIGNATURE_SIZE``)."""
_MAX_ADVERT_DATA_SIZE = 32
"""Most app-data bytes an advert carries and signs (firmware
``MAX_ADVERT_DATA_SIZE``; ``Mesh::createAdvert`` refuses more)."""
# libsodium's small-order blocklist, ge25519_has_small_order() in
# src/libsodium/crypto_core/ed25519/ref10/ed25519_ref10.c, compared as there:
# the first 31 bytes exactly, byte 31 with its top bit (the sign of x) masked.
# The seven values are every encoding of the eight points of order 1, 2, 4 and
# 8 on the RFC 8032 curve (section 5.1), non-canonical y = p and y = p + 1
# included; tests/ed25519_oracle.py derives them from the RFC's constants.
_SMALL_ORDER_KEYS = frozenset(
bytes.fromhex(encoding)
for encoding in (
"00" * 32, # 0 (order 4)
"01" + "00" * 31, # 1 (order 1)
"26e8958fc2b227b045c3f489f2ef98f0d5dfac05d3c63339b13802886d53fc05", # order 8
"c7176a703d4dd84fba3c0b760d10670f2a2053fa2c39ccc64ec7fd7792ac037a", # order 8
"ec" + "ff" * 30 + "7f", # p - 1 (order 2)
"ed" + "ff" * 30 + "7f", # p (= 0, order 4)
"ee" + "ff" * 30 + "7f", # p + 1 (= 1, order 1)
)
)
"""Public-key encodings of small order, the sign bit cleared (SPEC SG4)."""
def _has_small_order(pub_key: bytes) -> bool:
"""Return whether a 32-byte public key encodes a point of small order.
No private key stands behind such a key and trivial signatures verify
under it, so its signature proves nothing. The sign bit is masked as
libsodium masks it, so the sign-bit variants match too, among them the
three that OpenSSL accepts and the firmware's decoder refuses (``x = 0``
with the sign bit set): ``01 00..00 80``, ``ec ff..ff ff`` and
``ee ff..ff ff``.
Parameters:
pub_key: The advert's raw public key.
Returns:
``True`` when the key is on :data:`_SMALL_ORDER_KEYS`.
"""
return pub_key[:31] + bytes([pub_key[31] & 0x7F]) in _SMALL_ORDER_KEYS
def _rx_advert_signature_problem(frame: dict) -> str | None:
"""Return why an RX-log advert's signature does not hold, or ``None``.
Checks what ``Mesh::onRecvPacket`` checks, on the raw payload the parser
read its ``adv_*`` fields from: an Ed25519 signature by the advert's own
public key over ``pub_key + timestamp + app_data`` (``Mesh::createAdvert``).
The payload is laid out ``pub_key(32) + timestamp(4) + signature(64) +
app_data``. Three rules go past the firmware's, so that every field the
ingestor posts is signed by a key some private key stands behind:
* App data longer than 32 bytes is refused. The firmware never sends
more and verifies only the first 32 bytes of a longer frame, while the
parser reads the name to the end of the frame.
* The parsed ``adv_key``, which names the posted node, must be the key
in the signed bytes.
* A key of small order is refused (:func:`_has_small_order`, SPEC SG4).
Parameters:
frame: Parsed RX-log ``ADVERT`` frame. Reads ``pkt_payload`` (the raw
packet payload as ``bytes`` or ``bytearray``) and ``adv_key``.
Returns:
``None`` when the signature holds, else the reason it does not:
``"no raw payload"``, ``"truncated signature"``, ``"app data over 32
bytes"``, ``"key mismatch"``, ``"small-order key"`` or ``"invalid
signature"``.
"""
raw = frame.get("pkt_payload")
if not isinstance(raw, (bytes, bytearray)):
return "no raw payload"
raw = bytes(raw)
if len(raw) < _APP_DATA_OFFSET:
return "truncated signature"
if len(raw) - _APP_DATA_OFFSET > _MAX_ADVERT_DATA_SIZE:
return "app data over 32 bytes"
pub_key = raw[:_PUB_KEY_SIZE]
if frame.get("adv_key") != pub_key.hex():
return "key mismatch"
if _has_small_order(pub_key):
return "small-order key"
# The signed message skips the signature itself: key, timestamp, app data.
message = raw[:_SIGNATURE_OFFSET] + raw[_APP_DATA_OFFSET:]
try:
Ed25519PublicKey.from_public_bytes(pub_key).verify(
raw[_SIGNATURE_OFFSET:_APP_DATA_OFFSET], message
)
except InvalidSignature:
return "invalid signature"
return None
def _rx_advert_signed_timestamp(frame: dict) -> int:
"""Return the sender-side timestamp an RX-log advert's signature covers.
Reads the four little-endian bytes after the public key in ``pkt_payload``,
the bytes the parser reports as ``adv_timestamp``. Call it only for a
frame whose signature holds (:func:`_rx_advert_signature_problem` returned
``None``): only then is the timestamp the advertiser's own.
Parameters:
frame: Parsed RX-log ``ADVERT`` frame whose signature holds.
Returns:
The signed ``uint32`` advert timestamp.
"""
raw = bytes(frame["pkt_payload"])
return int.from_bytes(raw[_PUB_KEY_SIZE:_SIGNATURE_OFFSET], "little")
@@ -20,6 +20,12 @@ import sys
import time
from ... import config, ingestors as _ingestors
from .advert_replay import (
_accept_advert_timestamp,
_raise_roster_advert,
_take_roster_listing,
)
from .advert_signature import _rx_advert_signature_problem, _rx_advert_signed_timestamp
from .decode import (
_advert_to_node_dict,
_contact_to_node_dict,
@@ -108,20 +114,32 @@ def _process_self_info(
def _process_contacts(
contacts: dict, iface: _MeshcoreInterface, handlers: object
contacts: dict,
iface: _MeshcoreInterface,
handlers: object,
*,
full_listing: bool = False,
) -> None:
"""Apply a bulk ``CONTACTS`` payload: update the local snapshot and upsert nodes.
The listing also sets which keys the RX-log replay memory treats as the
radio's roster (SPEC SG5, :func:`.advert_replay._take_roster_listing`).
Parameters:
contacts: Mapping of full ``public_key`` hex strings to contact dicts.
iface: Active interface whose contact snapshot will be updated.
handlers: Module reference for :func:`~data.mesh_ingestor.handlers`.
full_listing: ``True`` when *contacts* is the radio's whole roster
(the first listing of a connection), ``False`` for a listing of
changed contacts only (the auto-update re-fetch).
"""
last_adverts: dict[str, object] = {}
for pub_key, contact in contacts.items():
node_id = _meshcore_node_id(pub_key)
if node_id is None:
continue
iface._update_contact(contact)
last_adverts[pub_key] = contact.get("last_advert")
handlers.upsert_node(node_id, _contact_to_node_dict(contact))
lat = contact.get("adv_lat")
lon = contact.get("adv_lon")
@@ -139,6 +157,10 @@ def _process_contacts(
pub_key,
rx_time=last_advert,
)
# The firmware verified each listed contact's last advert: the listing
# sets the roster pool, whose timestamps refuse an RX-log copy of those
# adverts, or older ones (SPEC SG5).
_take_roster_listing(last_adverts, full=full_listing)
# Companion-link roster fetch, not over-air frames: clock only (Model A).
handlers._mark_packet_activity()
@@ -158,6 +180,9 @@ def _process_contact_update(
if node_id is None:
return
iface._update_contact(contact)
# Raises a roster member's timestamp only, never adds a member: the radio
# pushes NEW_CONTACT for an advert from a key it did not add (SPEC SG5).
_raise_roster_advert(pub_key, contact.get("last_advert"))
handlers.upsert_node(node_id, _contact_to_node_dict(contact))
lat = contact.get("adv_lat")
lon = contact.get("adv_lon")
@@ -215,8 +240,18 @@ def _make_event_handlers(iface: _MeshcoreInterface, target: str | None) -> dict:
async def on_self_info(evt) -> None:
_process_self_info(evt.payload or {}, iface, _handlers)
# The first CONTACTS listing of a connection is the radio's whole roster:
# each connection builds a new MeshCore, whose first fetch asks with
# since = 0. Later listings come from the auto-update re-fetch, which asks
# only for contacts changed since the newest lastmod (SPEC SG5).
full_listing_seen = False
async def on_contacts(evt) -> None:
_process_contacts(evt.payload or {}, iface, _handlers)
nonlocal full_listing_seen
_process_contacts(
evt.payload or {}, iface, _handlers, full_listing=not full_listing_seen
)
full_listing_seen = True
async def on_contact_update(evt) -> None:
_process_contact_update(evt.payload or {}, iface, _handlers)
@@ -363,8 +398,9 @@ def _make_event_handlers(iface: _MeshcoreInterface, target: str | None) -> dict:
# "what is in the air" — while the decoded high-level events
# (CHANNEL_MSG_RECV, CONTACT_MSG_RECV, ADVERTISEMENT, telemetry
# responses) are duplicates of these same frames and only advance the
# reconnect clock. Counting precedes the DEBUG-capture drop and the
# malformed-advert skip, so no received frame — ignored, errored, or
# reconnect clock. Counting precedes the DEBUG-capture drop, the
# malformed-advert skip and the signature and replay checks, so no
# received frame — ignored, errored, forged, replayed, or
# unimplemented — is under-reported.
_handlers._mark_packet_seen()
# Remember each decrypted channel-message copy so on_channel_msg can
@@ -395,6 +431,30 @@ def _make_event_handlers(iface: _MeshcoreInterface, target: str | None) -> dict:
)
return
# The radio logs every frame before the firmware checks it, and the
# library parses an advert without checking its signature, so an
# RX-log advert posts its node and position only when its signature
# holds under a key not of small order (SPEC SG1, SG4) and its signed
# timestamp is newer than the last one accepted for that key (SG5):
# a replay, or the same advert over another path, posts nothing. The
# replay check runs only on a verified advert, so a forged timestamp
# is never remembered. The drop logs at debug level: a forged, broken
# or repeated frame is per-packet noise a warning would flood the log
# with.
reason = _rx_advert_signature_problem(payload)
if reason is None and not _accept_advert_timestamp(
pub_key, _rx_advert_signed_timestamp(payload)
):
reason = "replayed advert"
if reason is not None:
config._debug_log(
"Unverified RX-log advert dropped",
context="meshcore.rx_advert",
node_id=node_id,
reason=reason,
)
return
_handlers.upsert_node(node_id, _rx_advert_to_node_dict(payload))
lat = payload.get("adv_lat")
lon = payload.get("adv_lon")
+1 -1
View File
@@ -9,5 +9,5 @@ protobuf==7.36.2
# armv7 images compile cryptography from source with the Rust of the base
# image's Alpine (3.24: Rust 1.96); a release needing a newer Rust breaks that
# build (DK-A2), so check its rust-version before bumping.
cryptography==50.0.2 # AES-CTR decryption for the passive UDP transport
cryptography==50.0.2 # UDP transport AES-CTR, MeshCore RX-log advert signatures
rns==1.5.6 # Reticulum announce listener (PROTOCOL=reticulum)