Files
MeshTerm/meshterm/core/frames.py
T
Jean-Pierre MartineauandClaude Opus 5 01ca242c98 Live feed: the lane is about the packet, whatever the packet is about
The NODE lane suited the two classes that name a node and left every
other one reading a dash. On this mesh that is half the traffic: the
direct messages, requests, responses, returned paths, acks and traces
that relay past us naming no origin. So the lane asks each class its own
question instead, and the header says SUBJECT — the word that stays true
down the whole column.

An advert or a telemetry frame is still about its node. A channel text is
about its channel, named by the key whose MAC confirms the frame — never
by the fingerprint alone, which 256 buckets make no proof at all; a
channel we hold no key for reads as that fingerprint and says so. A
direct message, request, response, returned path or anonymous request is
about the two nodes it travels between, drawn sender → recipient on the
hop-sequence widget, so endpoints read exactly as route hops do: resolved
names, key-derived hues, our own node white the moment a frame is
addressed to us. Too wide, and the sender drops to its hash — the frame
is addressed, so the addressee is the half worth the cells. An ack is
about the message it answers, a trace about its tag. And an unsigned
channel post is filed under its channel rather than the bare word
"channel", which named nothing.

Half of that was unreadable before because nobody had opened the frame.
The companion hands every overheard packet to the library, which decodes
the body for exactly two classes — advert and channel text — and leaves
the rest as bytes. Those bytes are not opaque: MeshCore addresses a frame
in the first few of them, and core/frames.py now reads them, class by
class, against the layouts the protocol's own docs/payloads.md publishes.
The trace tag is the little-endian uint32 the firmware writes, so it
reads as the same number a trace reply reports; an ack's checksum keeps
wire order, the form the companion reports its own delivery acks in. Two
checks guard every decode, because a wrong slice yields not an error but
a plausible-looking hash: the frame's payload version must be v1 (the
header reserves two bits for a v2 that widens both hash and MAC), and the
body must be long enough for the layout its class promises. Either
failing yields nothing at all.

The feed opens seeded from history, so any of this that lived only in a
live event's raw payload would leave the opening screen blank — the same
problem the channel-text crypto trio had. Same answer: three columns
(dest/src/tag), written on capture and restored to the very keys the
event used, so a replayed frame and a fresh one read identically all the
way up. Older rows carry NULL until re-heard.

The viewer gained the matching rows: to, from, tag, acks, and a channel
for a datagram — whose body is not text, but whose MAC still names it.

Verified against the MeshCore protocol documentation and firmware source,
then corroborated on the air: every frame captured off the radio has a
body length that decomposes exactly into its documented layout, an
anonymous request's 51 bytes (1 + 32 + 2 + one AES block) included.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-28 19:11:07 -04:00

163 lines
8.5 KiB
Python

"""What an overheard frame is *addressed to* — the packet body nobody else parses.
The companion's RX packet log hands every overheard frame to the meshcore library, which
splits off the header it always understands — route type, payload class, relay path — and
leaves the rest as an undecoded ``pkt_payload`` blob. The library then decodes that blob
for exactly two classes: an advert (the identity and location it carries) and a
channel-text frame (its channel fingerprint, MAC and ciphertext). Every other class — the
direct messages, requests, responses, path returns, acks and traces that make up half of
what a busy mesh puts on the air — arrives as bytes, which is why an origin-less flood
used to have nothing to say about itself beyond its class.
Those bytes are not opaque. MeshCore addresses a frame in the first few of them, and the
layout is fixed per payload class — as published in the protocol's own ``docs/payloads.md``
and ``docs/packet_format.md``:
=========================== ==========================================================
class body layout
=========================== ==========================================================
``REQ``/``RESPONSE``/ ``[dest hash:1][src hash:1][MAC:2][ciphertext]``
``TEXT_MSG``/``PATH``
``ANON_REQ`` ``[dest hash:1][sender key:32][MAC:2][ciphertext]``
``ACK`` ``[checksum:4]``
``TRACE`` ``[tag:4][auth code:4][flags:1][hops…]``
``GRP_TXT``/``GRP_DATA`` ``[channel hash:1][MAC:2][ciphertext]``
=========================== ==========================================================
Endpoints are named by a *hash* — the leading byte of the node's public key, the same
one-byte identity the relay path's hops use — so they resolve through the app's ordinary
node resolver and land on the same names, hues and collisions as every hop does. An
anonymous request is the exception: having no shared secret to be recognised by yet, it
carries its sender's whole public key (the protocol docs' "sender's Ed25519 public key" —
the ``Packet.h`` header comment still calls it "ephemeral", but the login payloads it
carries are addressed to a node that has to know who logged in, and the docs' field table
is the later word). What is recovered here is only ever addressing: who a frame is for,
who it says it is from, which channel it belongs to, the token it carries. The ciphertext
is left alone (a channel we hold the key for is decrypted in
:mod:`~meshterm.core.channels`, by MAC-confirmed key, and nothing else on the mesh is ours
to read).
Two things are checked before a single byte is trusted, because a wrong slice would
produce not an error but a plausible-looking hash. The frame's **payload version** must be
the one these layouts describe — v1, the only version deployed, and the only one whose
hashes are one byte and whose MAC is two (the header reserves two bits for a v2 that
widens both). And the body must be **long enough** to hold the layout its class promises.
Either check failing yields nothing at all.
The recovered fields are merged straight into the frame's raw payload (see
:func:`~meshterm.core.connection.packet_observation_from_event`) under their own keys, so
every reader — the live feed's lane, the packet viewer's card, the repository's stored
columns — reads them exactly as it reads the two classes the library decoded itself.
"""
from __future__ import annotations
from typing import Any, Mapping
#: Payload classes MeshCore addresses with a pair of one-byte key hashes: the recipient
#: first, the sender second, then the MAC and the ciphertext. A direct message, a request
#: to a repeater, its response, and a returned path all share this envelope.
ADDRESSED_CLASSES = frozenset({"REQ", "RESPONSE", "TEXT_MSG", "PATH"})
#: Payload classes carrying the ``[channel hash][MAC][ciphertext]`` channel envelope: a
#: channel's text messages and its datagrams. Only ``GRP_DATA`` is decoded here — the
#: library already breaks ``GRP_TXT`` out itself, into these very same field names — but
#: both are stored, decrypted and named by the same code from here on.
CHANNEL_CLASSES = frozenset({"GRP_TXT", "GRP_DATA"})
#: Bytes of a public key a frame names an endpoint by — one, the same one-byte identity a
#: relay-path hop carries, which is why an endpoint resolves through the ordinary node
#: resolver (and collides exactly as a hop does). Fixed by :data:`_PAYLOAD_V1`.
ENDPOINT_HASH_BYTES = 1
#: The payload version these layouts describe: 1-byte endpoint hashes and a 2-byte MAC.
#: The header's two version bits reserve a v2 that widens both, so a frame announcing any
#: other version is left undecoded rather than sliced by v1's offsets.
_PAYLOAD_V1 = 0
#: Bytes of the MAC that follows the addressing.
_MAC = 2
#: Bytes of a full public key, carried whole by an anonymous request's sender.
_KEY = 32
#: Bytes of a frame's own token — an ack's checksum, a trace's tag.
_TOKEN = 4
def frame_addressing(payload: Mapping[str, Any]) -> dict[str, str]:
"""Recover what a raw RX-logged frame addresses, from its undecoded body.
Reads the leading bytes of ``pkt_payload`` according to the frame's payload class
(see the module docstring's table), and returns them as hex under the raw-payload keys
the rest of the app reads:
* ``dest_hash`` — the recipient's one-byte key hash (every addressed class).
* ``src_hash`` — the sender's one-byte key hash (the two-hash classes).
* ``src_key`` — the sender's *whole* public key, which an anonymous request carries
instead of a hash (it has no shared secret to be recognised by yet).
* ``ack_crc`` — an ack's four-byte checksum of the message it acknowledges, hex in
wire order: the same form the companion reports its *own* delivery acks in, so the
two can be compared.
* ``trace_tag`` — a trace's tag, the token its reply is matched by. Read as the
little-endian ``uint32`` the firmware writes and shown as eight hex digits, so it
reads as the same number a trace reply's ``tag`` does rather than byte-reversed.
* ``chan_hash``/``cipher_mac``/``crypted`` — a channel datagram's envelope, the same
three fields the library breaks a channel *text* frame into.
Hashes and keys are the bytes as they sit on the wire, hex-encoded — a hash reads
exactly as the path hops beside it do.
Args:
payload: The frame's raw payload, as the RX-log event carried it (needs
``payload_typename`` and the undecoded ``pkt_payload`` bytes; ``payload_ver``
is honoured when present).
Returns:
The recovered fields, or an empty mapping when the class carries no addressing,
the body is missing, the frame announces a payload version these layouts don't
describe, or the body is too short to hold the layout its class promises.
"""
typename = payload.get("payload_typename")
body = payload.get("pkt_payload")
if not typename or not isinstance(body, (bytes, bytearray)):
return {}
version = payload.get("payload_ver")
if version is not None and version != _PAYLOAD_V1:
return {} # a wider hash/MAC: v1's offsets would slice the wrong bytes
body = bytes(body)
hash_w = ENDPOINT_HASH_BYTES
if typename in ADDRESSED_CLASSES:
if len(body) < hash_w * 2 + _MAC:
return {}
return {
"dest_hash": body[:hash_w].hex(),
"src_hash": body[hash_w : hash_w * 2].hex(),
}
if typename == "ANON_REQ":
if len(body) < hash_w + _KEY + _MAC:
return {}
return {
"dest_hash": body[:hash_w].hex(),
"src_key": body[hash_w : hash_w + _KEY].hex(),
}
if typename == "ACK":
if len(body) < _TOKEN:
return {}
return {"ack_crc": body[:_TOKEN].hex()}
if typename == "TRACE":
# tag, auth code, flags — the shortest trace is one that collected no hops yet.
if len(body) < _TOKEN * 2 + 1:
return {}
# The firmware memcpy's the tag straight out of a uint32, and the library reads a
# trace reply's back the same way: little-endian, so the two agree on the number.
return {"trace_tag": f"{int.from_bytes(body[:_TOKEN], 'little'):08x}"}
if typename == "GRP_DATA": # GRP_TXT's twin, which the library leaves undecoded
if len(body) < hash_w + _MAC:
return {}
return {
"chan_hash": body[:hash_w].hex(),
"cipher_mac": body[hash_w : hash_w + _MAC].hex(),
"crypted": body[hash_w + _MAC :].hex(),
}
return {}