Files
Jean-Pierre MartineauandClaude Opus 5.5 73be9692b4 A channel guide lists the public channels heard on the mesh
MeshTerm already stores every channel packet it hears, including those
on channels the device doesn't have. A packet names its channel only by
a one-byte hash, but a public channel's key is derived from its name, so
a candidate name can be tested: derive the key and check the packet's
MAC with it, exactly as the firmware does before decoding. The guide
tries the device's channels, Public, every #name mentioned in a stored
message, and every known region. On one desktop history that names 25
public channels.

A guessed name has to match at least two messages before the guide
shows it. The check compares three bytes, and with thousands of names
against thousands of packets roughly one false match is expected. The
candidates are grouped by their hash byte, so each packet only checks
the ~1/256 that could match; the naive loop took 40 s, this takes 1 s
on 31,000 messages, in a worker thread under a busy card, and the result
is kept for the session while its inputs are unchanged.

"Channel guide…" is the first row of "Add a channel" on the Channels
page, and it stays there when every slot is full. Channels not on the
device come first, and Enter adds one to the next free slot. Channels
the device already has follow with a ✓ the highlight doesn't walk, and a
closing line counts the messages no name matched. Each row shows the
message count, the age of the last message and seven days of activity.
`channels guide` lists the same channels on the command line.

Refs: #25
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-07 01:27:11 -04:00

249 lines
9.6 KiB
Python

# SPDX-License-Identifier: Apache-2.0
"""The channel guide: the public channels that MeshTerm heard, and their traffic.
MeshTerm stores each channel packet that it hears, also on a channel for which it has no
key (the ``chan_hash``, ``cipher_mac``, and ``crypted`` columns of ``observations``). A
packet names its channel only by a one-byte hash, so the packet alone does not give the
name. But the key of a public channel comes from its name (``sha256("#name")``). Thus
MeshTerm can test a candidate name: it derives the key, and it checks the MAC of the packet
with that key, exactly as the firmware does before it decodes a packet. A name whose key
gives the MAC of the packet is the name of the channel.
The candidate names come from what MeshTerm already knows:
- The channels with a key that MeshTerm has: the slots of the device, and the ``Public``
channel of the firmware.
- Each ``#name`` that a stored message mentions.
- Each region that MeshTerm knows, as ``#region``.
A match of a guessed name on one message is not proof. The check compares three bytes (the
hash and the two-byte MAC). With thousands of names against thousands of packets,
approximately one false match is probable. Thus the guide names a guessed channel only when
:data:`MIN_MATCHES` messages or more match. A channel with a key that MeshTerm has needs one
match, because its name is not a guess.
The check is fast because the candidates are in groups by their hash byte. Each packet
checks only the candidates with its own hash, which is approximately 1/256 of them. A loop
over all the candidates for each packet took 40 s on a desktop for one history
(2026-10-06).
This module is pure logic. The caller reads the packets from the database and gives them
here. Thus the matching can run in a worker thread, and the database stays on its own
thread.
"""
from __future__ import annotations
import hmac
import re
from collections.abc import Iterable
from dataclasses import dataclass, field
from datetime import datetime, timedelta, timezone
from .channels import (
DEFAULT_PUBLIC_SECRET,
channel_hash,
channel_identity,
derive_secret,
effective_secret,
is_public_channel,
)
#: The number of different messages that a *guessed* name must match before the guide
#: names the channel. Refer to the module docstring for the reason.
MIN_MATCHES = 2
#: The time window of the activity chart of each row: the last seven days.
GUIDE_WINDOW = timedelta(days=7)
#: The number of buckets in the window, newest first. Each bucket is six hours, and a
#: braille cell draws two buckets, so the full chart is 14 cells for 7 days.
GUIDE_BUCKETS = 28
#: A ``#name`` in the text of a message: a ``#`` that does not follow a letter, a digit, or
#: another ``#``, then 2 to 31 letters, digits, ``_``, or ``-``. Thus ``C#`` and ``##`` are
#: not names, and ``#10`` is one.
MENTION = re.compile(r"(?<![\w#])#[A-Za-z0-9][A-Za-z0-9_-]{1,30}")
def mention_names(texts: Iterable[str | None]) -> set[str]:
"""Each ``#name`` that the texts mention, as written and in lowercase.
The key comes from the exact bytes of the name, so ``#Ottawa`` and ``#ottawa`` are two
channels. A person who mentions a channel often writes it with a capital letter that
the channel does not have. Thus the function gives both forms.
"""
names: set[str] = set()
for text in texts:
for found in MENTION.findall(text or ""):
names.add(found)
names.add(found.lower())
return names
@dataclass(frozen=True, slots=True)
class HeardChannel:
"""One public channel that MeshTerm heard, with its traffic.
Attributes:
name: The name of the channel.
key: The 16-byte key that matched the MAC of its packets.
messages: The number of different messages heard (copies that repeaters relayed
count one time).
first_heard: When MeshTerm first heard a message of the channel.
last_heard: When MeshTerm last heard a message of the channel.
histogram: The messages in each bucket of :data:`GUIDE_WINDOW`, newest first.
"""
name: str
key: bytes
messages: int
first_heard: datetime | None
last_heard: datetime | None
histogram: tuple[int, ...]
@property
def identity(self) -> str:
"""The identity of the channel, the same value that a slot of the device gives."""
return channel_identity(self.name, self.key)
@property
def hash(self) -> str:
"""The channel hash (two hex digits) that the packets of the channel carry."""
return channel_hash(self.key)
@property
def write_secret(self) -> bytes | None:
"""The secret to send when the channel is added: ``None`` when the name gives it."""
return None if self.key == derive_secret(self.name) else self.key
@dataclass(frozen=True, slots=True)
class Guide:
"""What the guide found in the stored packets.
Attributes:
channels: The public channels that it named, the most recently heard first.
unnamed: The number of different messages that no name matched: messages on
private channels, or on public channels whose name nobody wrote.
"""
channels: tuple[HeardChannel, ...] = ()
unnamed: int = 0
@dataclass(slots=True)
class _Tally:
"""The counts of one candidate while the guide reads the packets."""
name: str
key: bytes
certain: bool
messages: int = 0
first: datetime | None = None
last: datetime | None = None
histogram: list[int] = field(default_factory=lambda: [0] * GUIDE_BUCKETS)
def build_guide(
packets: Iterable[tuple[str, str, str, str | None, str | None]],
known: Iterable[tuple[str, bytes]],
guesses: Iterable[str],
*,
now: datetime,
) -> Guide:
"""Name the channels of the stored packets, and count their traffic.
Args:
packets: One row for each different message: its channel hash, its MAC, and its
ciphertext (all in hex), then when it was first and last heard (ISO 8601).
known: The channels with a key that MeshTerm has, as ``(name, key)``. The
``Public`` channel of the firmware is always added. A private channel here
names its own packets, so that they do not count as unnamed, but the guide does
not show it, because it is not public.
guesses: The candidate names (``#name``) to test.
now: The present time, for the activity chart.
Returns:
The guide.
"""
tallies: dict[str, _Tally] = {}
for name, secret in [("Public", DEFAULT_PUBLIC_SECRET), *known]:
key = effective_secret(name, secret)
tallies.setdefault(channel_identity(name, key), _Tally(name, key, certain=True))
for name in guesses:
key = derive_secret(name)
tallies.setdefault(channel_identity(name, key), _Tally(name, key, certain=False))
by_hash: dict[str, list[_Tally]] = {}
for tally in tallies.values(): # the known channels first, in each group
by_hash.setdefault(channel_hash(tally.key), []).append(tally)
bucket_span = GUIDE_WINDOW / GUIDE_BUCKETS
unnamed = 0
for chan_hash, cipher_mac, crypted, first_at, last_at in packets:
tally = _match(by_hash.get((chan_hash or "").lower(), ()), cipher_mac, crypted)
if tally is None:
unnamed += 1
continue
first, last = _when(first_at), _when(last_at)
tally.messages += 1
if first is not None:
tally.first = first if tally.first is None else min(tally.first, first)
age = now - first
if timedelta(0) <= age < GUIDE_WINDOW:
tally.histogram[int(age / bucket_span)] += 1
if last is not None:
tally.last = last if tally.last is None else max(tally.last, last)
channels: list[HeardChannel] = []
for tally in tallies.values():
if tally.messages < (1 if tally.certain else MIN_MATCHES):
unnamed += tally.messages # too few to name it, so it stays unnamed
continue
if not is_public_channel(tally.name, tally.key):
continue # a private channel of the device: named, but not for the guide
channels.append(
HeardChannel(
name=tally.name,
key=tally.key,
messages=tally.messages,
first_heard=tally.first,
last_heard=tally.last,
histogram=tuple(tally.histogram),
)
)
epoch = datetime.min.replace(tzinfo=timezone.utc)
channels.sort(key=lambda c: (c.last_heard or epoch, c.messages), reverse=True)
return Guide(channels=tuple(channels), unnamed=unnamed)
def _match(candidates: Iterable[_Tally], cipher_mac: str, crypted: str) -> _Tally | None:
"""The candidate whose key gives the MAC of the packet, or ``None``.
This is the check of :func:`~meshterm.core.channels.identify_channel`, with the
``hmac`` module of the standard library. That module does the whole HMAC in C, and it
needs no import of PyCryptodome on the path of the guide.
"""
try:
mac = bytes.fromhex(cipher_mac or "")
body = bytes.fromhex(crypted or "")
except ValueError:
return None
if not mac or not body:
return None
for tally in candidates:
if hmac.digest(tally.key, body, "sha256")[: len(mac)] == mac:
return tally
return None
def _when(text: str | None) -> datetime | None:
"""Parse a stored time. A time with no zone is UTC, as MeshTerm stores it."""
if not text:
return None
try:
when = datetime.fromisoformat(text)
except ValueError:
return None
return when if when.tzinfo is not None else when.replace(tzinfo=timezone.utc)