mirror of
https://github.com/l5yth/potato-mesh.git
synced 2026-10-31 03:51:58 +01:00
data: check MeshCore RX-log adverts against their signatures (#996)
This commit is contained in:
11 files changed
+1834
-74
No files matched your search
@@ -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")
|
||||
|
||||
@@ -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)
|
||||
Reference in new issue
Block a user