Files
richandClaude Opus 5.5 f3aa8e5aef Allow channel sender names of up to 31 characters
The firmware keeps a node name in 32 bytes, but the sender prefix and the @[Name]
mention matched only 20 characters. A longer name stayed in the body: the transcript
showed no sender for it, and its reactions showed as new messages. Found on hardware
with "Hazelwood r0b3.Anduril" (22 characters).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 07:47:13 -04:00

456 lines
19 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# SPDX-License-Identifier: Apache-2.0
"""Pure channel logic: secret derivation, MeshCore share URLs (build and parse), and decryption.
A MeshCore channel is a name and a 16-byte shared secret. A *public* channel (its name
starts with ``#``) derives its secret from the name, always in the same way. Thus each
person who gives the channel the same name gets the same key. A *private* channel carries a
random secret, which its users share outside the mesh.
This module holds that logic, with no dependency on the device or on I/O. Thus the create
and join flows, and the ``meshcore://`` share links in the QR codes, can be built,
validated, and unit-tested without hardware. :func:`decrypt_channel_text` uses the same AES
key on an overheard channel text packet that is still encrypted (the packet viewer uses it
for this). Thus the app does not need the decode of the device itself to read a channel for
which it has the key.
"""
from __future__ import annotations
import re
import secrets
from collections.abc import Iterable
from dataclasses import dataclass
from datetime import datetime, timezone
from hashlib import sha256
from urllib.parse import parse_qs, quote, urlsplit
# PyCryptodome is imported in the two functions that need it, never here. The import
# probes the crypto features of the host CPU (``Crypto.Util._cpu_features``). This costs a
# fifth of a second on the Cortex-A7 of the PicoCalc, and each startup pays it, because this
# module is on the boot path through one constant: core.connection imports
# CHANNEL_SLOT_PROBE_CAP, and core.channel_store imports the derivation helpers. Neither of
# them decrypts anything at import. When the import is deferred, the cost moves to the first
# overheard channel packet, where the work is necessary. Each later call only gets the
# module from the sys.modules dict. Both functions bind the names one time for each call,
# not one time for each candidate channel. Thus the reception path pays almost nothing.
#: Matches the ``Name: message`` convention that channel senders use to identify themselves
#: (the protocol has no sender field). The name is 1–31 characters that are not colons, and
#: ``": "`` must follow it. This rule is strict enough that it does not match ``http://…``
#: or ``note:x``. The firmware keeps a node name in 32 bytes with its terminator, so 31 is
#: the longest name that a sender can have. A limit of 20 left
#: a longer name in the body. Then the transcript showed no sender for it, and a reaction
#: from it did not read as a reaction.
SENDER_PREFIX = re.compile(r"^([^\s:][^:]{0,30}):[ \t]+(.*)$", re.DOTALL)
#: Matches an ``@[Name]`` mention token, which the reply flow puts into the compose line in
#: advance. A transcript renders each token as a bare ``@Name`` in the hue of that sender,
#: and does not show the brackets. The history face of the CLI also takes out the same
#: names. Thus the token is defined here, with the remainder of the message-body parsing, and
#: not in one of the two renderers. The name is 1–31 characters that are not ``]``, the
#: same limit as :data:`SENDER_PREFIX`.
MENTION = re.compile(r"@\[([^\]]{1,31})\]")
def split_channel_sender(text: str) -> tuple[str | None, str]:
"""Split a channel message into ``(sender_name, body)`` when it has a name prefix.
Channel messages have no sender field on the wire. Thus senders identify themselves
with the prefix ``Name: `` at the start of the text. When this function takes out that
name, a transcript can show it as a coloured header, and a feed can name the sender.
Also, the message-paths matcher can compare a body from the air with a stored body.
Args:
text: The raw text of the channel message.
Returns:
``(name, body)`` when a plausible ``Name: `` prefix is present, else
``(None, text)``.
"""
match = SENDER_PREFIX.match(text)
if match is None:
return None, text
name, body = match.group(1).strip(), match.group(2)
if not name or name.isdigit() or body.startswith("//"): # not a name: a URL or a timestamp
return None, text
return name, body
#: The MeshCore protocol sets the shared secret of a channel to exactly 16 bytes (128 bits).
CHANNEL_SECRET_BYTES = 16
#: The number of channel slots that MeshTerm assumes when it cannot probe a device. Stock
#: MeshCore companion firmware is built with 8 slots.
#: :meth:`meshterm.core.connection.Device.channel_capacity` finds the real number from the
#: hardware at runtime, so this value is only the fallback.
MAX_CHANNELS = 8
#: The maximum for a slot probe. The wire addresses a channel slot with one byte, so this
#: value is much larger than the slot count of any real firmware. But it still limits a scan
#: of a device that never rejects an index that is out of range (so that the probe cannot
#: loop forever).
CHANNEL_SLOT_PROBE_CAP = 64
#: A sequence of this many empty slots, one after the other, ends a scan for configured
#: channels (refer to :func:`meshterm.core.channel_probe.read_channel_slots`) on firmware
#: that never rejects an index that is out of range. Some builds answer each index with an
#: empty payload instead of an error. Without this limit, the scan walks all
#: :data:`CHANNEL_SLOT_PROBE_CAP` slots at each read. The channel manager puts the channels
#: in sequence from slot 0 up, in the stock :data:`MAX_CHANNELS` slots. Thus no real layout
#: has this many empty slots before its last channel, and a sequence of this length means
#: that the scan has gone past the configured channels. Firmware that does reject an index
#: that is out of range still ends the scan at its true maximum first. (We do not apply this
#: limit to :meth:`meshterm.core.connection.Device.channel_capacity`, on purpose. That
#: method must report the real slot count of a firmware that has more slots and rejects an
#: index. Thus it must get to the rejection, and not guess from a sequence of empty slots.
#: Instead, its cost is limited because MeshTerm caches the result for the session.)
CHANNEL_SLOT_EMPTY_RUN = MAX_CHANNELS
#: The default public channel that each MeshCore device has on slot 0 when it ships.
DEFAULT_PUBLIC_NAME = "public"
#: The built-in public channel of MeshCore ships on slot 0 as ``Public``, with this fixed
#: 16-byte key. This key does not derive from the name. Thus the ``#`` and name-key
#: heuristics cannot recognize the channel, and MeshTerm must match it by its well-known
#: secret instead.
DEFAULT_PUBLIC_SECRET = bytes.fromhex("8b3387e9c5cdea6ac9e5edbaa115cd72")
#: The secrets of well-known public channels. These channels are public (shared on the whole
#: mesh), whatever their name is, and whether or not their key derives from the name. Today
#: the set holds only the firmware default above. It is a frozenset, so that more
#: authoritative public channels can be added later.
KNOWN_PUBLIC_SECRETS = frozenset({DEFAULT_PUBLIC_SECRET})
def is_public_name(name: str) -> bool:
"""Whether a channel name is a public name, from which the key derives.
Args:
name: The channel name.
Returns:
``True`` for a name with a ``#`` prefix (the firmware convention for a key that
derives from the name), else ``False``.
"""
return name.startswith("#")
def is_name_derived(name: str, secret: bytes) -> bool:
"""Whether the key of a channel can be calculated from its name.
``True`` for a name with a ``#`` prefix, and for each channel whose stored secret is
already equal to ``derive_secret(name)``. Such a channel does not have to store its key,
because the firmware calculates the key again from the name. Thus, to re-key or reorder
this channel, MeshTerm can send no secret at all.
Args:
name: The channel name.
secret: The 16-byte secret stored in the slot.
Returns:
``True`` if the name alone gives the key.
"""
return is_public_name(name) or bytes(secret) == derive_secret(name)
def is_public_channel(name: str, secret: bytes) -> bool:
"""Whether a channel is public: shared across the mesh, not a private channel off the mesh.
A channel is public when its key derives from its name (refer to
:func:`is_name_derived`), or when it is a :data:`KNOWN_PUBLIC_SECRETS` channel. The
important example is the firmware default ``Public``. Its key is a fixed, well-known
value, not a value that derives from its name. This second case is why a check of only
the name or the ``#`` is not sufficient: that check labels this channel as private.
Args:
name: The channel name.
secret: The 16-byte secret stored in the slot.
Returns:
``True`` if the channel is public.
"""
return is_name_derived(name, secret) or bytes(secret) in KNOWN_PUBLIC_SECRETS
def derive_secret(name: str) -> bytes:
"""Derive the 16-byte secret of a channel from its name, exactly as the firmware does.
A public channel (with a ``#`` prefix), or any channel created without an explicit key,
uses ``sha256(name)[:16]``. Thus all persons who type the same name share the same key.
Args:
name: The channel name (with the leading ``#``, if the name has one).
Returns:
The derived 16-byte secret.
"""
return sha256(name.encode("utf-8")).digest()[:CHANNEL_SECRET_BYTES]
def random_secret() -> bytes:
"""Generate a new, cryptographically random 16-byte secret for a private channel.
Returns:
16 random bytes.
"""
return secrets.token_bytes(CHANNEL_SECRET_BYTES)
def normalize_secret(text: str) -> bytes:
"""Parse a secret that the user gives (32 hex characters, that is 16 bytes).
Accepts whitespace around the text, an ``0x`` prefix, and spaces in the text. Thus a key
that is pasted in any usual form is accepted.
Args:
text: The secret as a hex string.
Returns:
The 16-byte secret.
Raises:
ValueError: If ``text`` is not exactly 16 bytes of hexadecimal.
"""
cleaned = text.strip().removeprefix("0x").replace(" ", "").replace(":", "")
data = bytes.fromhex(cleaned) # raises ValueError: an odd length, or not hex
if len(data) != CHANNEL_SECRET_BYTES:
raise ValueError("Channel secret must be exactly 16 bytes (32 hex characters).")
return data
def full_channel_hash(secret: bytes) -> str:
"""Return the full ``sha256(secret)`` hex digest (64 characters) for a channel.
Its first byte is the short :func:`channel_hash` that MeshCore reports. MeshTerm shows
the remainder only for a full fingerprint.
Args:
secret: The 16-byte secret of the channel.
Returns:
A 64-character lowercase hex string.
"""
return sha256(bytes(secret)).hexdigest()
def effective_secret(name: str, secret: bytes) -> bytes:
"""Return the key with which a channel encrypts, and resolve a public channel by name.
The key of a public channel derives from its name. Thus the key material that a device
has stored in the slot (some firmware and simulators keep zeros) is not its true secret.
A private channel uses its stored secret without a change.
Args:
name: The channel name (a leading ``#`` marks it as public).
secret: The 16-byte secret stored in the slot.
Returns:
The effective 16-byte secret of the channel.
"""
if is_name_derived(name, secret):
return derive_secret(name)
return bytes(secret)
def channel_identity(name: str, secret: bytes) -> str:
"""Return the stable identity of a channel, which does not depend on its slot.
The identity is the ``sha256`` of the :func:`effective_secret` of the channel. Thus it
comes from the channel itself (its key material), not from its position in the UX. A
new order of the channel slots never changes the identity of a channel. Each device that
shares the channel (a public channel by its name, a private channel by its exchanged
key) calculates the same value. Because the identity is a hash, the raw secret is not in
the places where the identity is stored.
Args:
name: The channel name.
secret: The 16-byte secret stored in the slot.
Returns:
A 64-character lowercase hex identity.
"""
return full_channel_hash(effective_secret(name, secret))
def channel_hash(secret: bytes) -> str:
"""Return the channel hash (2 hex characters) that MeshCore shows for a secret.
This is the first byte of ``sha256(secret)``. It is the same short fingerprint that the
companion reports for a channel. It helps the user to see quickly that two channels are
different.
Args:
secret: The 16-byte secret of the channel.
Returns:
A two-character lowercase hex string.
"""
return full_channel_hash(secret)[:2]
def share_url(name: str, secret: bytes) -> str:
"""Build the MeshCore ``meshcore://channel/add`` share URL for a channel.
The MeshCore mobile apps scan exactly this scheme. Thus a QR code rendered from the
returned string adds the channel directly in the app that scans it.
Args:
name: The channel name.
secret: The channel's 16-byte secret.
Returns:
A ``meshcore://channel/add?name=…&secret=…`` URL.
"""
return f"meshcore://channel/add?name={quote(name, safe='')}&secret={bytes(secret).hex()}"
def parse_share_url(url: str) -> tuple[str, bytes] | None:
"""Parse a ``meshcore://channel/add`` link into ``(name, secret)``.
Args:
url: A channel-add share URL (scanned from a QR code, or pasted).
Returns:
The channel name and its 16-byte secret, or ``None`` if the string is not a valid
MeshCore channel-add link.
"""
try:
parts = urlsplit(url.strip())
except ValueError:
return None
if parts.scheme != "meshcore":
return None
# The URL splits into netloc="channel" and path="/add". Join the two parts, and compare.
if f"{parts.netloc}{parts.path}".strip("/") != "channel/add":
return None
query = parse_qs(parts.query)
names = query.get("name")
keys = query.get("secret")
if not names or not keys:
return None
try:
return names[0], normalize_secret(keys[0])
except ValueError:
return None
@dataclass(slots=True)
class DecryptedText:
"""A channel text, recovered from an overheard, encrypted packet.
Attributes:
channel_name: The name of the channel whose key decrypted the packet.
text: The decrypted message body.
sent_at: The time on the clock of the sender when it composed the message. It is
present when the embedded timestamp parses to a usable value.
attempt: The resend counter of the sender for this message (0 = first try).
"""
channel_name: str
text: str
sent_at: datetime | None
attempt: int
def identify_channel(
chan_hash: str,
cipher_mac: str,
crypted: str,
channels: Iterable[tuple[str, bytes]],
) -> tuple[str, bytes] | None:
"""Name the channel of an overheard packet, and confirm the key by its MAC.
The packet identifies its channel only by the one-byte fingerprint of
:func:`channel_hash`, and a fingerprint is not proof. All the channels on the air go
into 256 buckets, so several channels can have the same fingerprint (and on a busy
mesh, they do). The MAC is the proof. Each candidate with a matching fingerprint gets an
HMAC check against the ciphertext, exactly as the firmware does before it decodes
anything. The function names a channel only if its key gives the same MAC as the packet.
This function only names the channel. This is why it is usable on a packet that
MeshTerm has no reason to decrypt. A channel datagram has the same envelope as a channel
text, but its body is not text at all, and a feed lane needs only the name of the
channel. :func:`decrypt_channel_text` does this same confirmation, and then the decode.
Args:
chan_hash: The channel-hash fingerprint of the packet (2 hex characters).
cipher_mac: The MAC of the packet, encoded as hex (2 bytes).
crypted: The ciphertext of the packet, encoded as hex.
channels: The candidate channels to try, as ``(name, secret)`` pairs.
Returns:
The ``(name, effective key)`` of the channel. ``None`` when the MAC of no known
channel matches: a channel for which we do not have the key, or only a collision
of fingerprints.
"""
from Crypto.Hash import HMAC # deferred: refer to the module header
from Crypto.Hash import SHA256 as _SHA256
try:
mac = bytes.fromhex(cipher_mac)
msg = bytes.fromhex(crypted)
except ValueError:
return None
if not msg:
return None
for name, secret in channels:
key = effective_secret(name, secret)
if channel_hash(key) != chan_hash:
continue
mac_check = HMAC.new(key, digestmod=_SHA256)
mac_check.update(msg)
if mac_check.digest()[:2] == mac:
return name, key
return None # an unknown channel, or the same fingerprint with a key that does not match
def decrypt_channel_text(
chan_hash: str,
cipher_mac: str,
crypted: str,
channels: Iterable[tuple[str, bytes]],
) -> DecryptedText | None:
"""Recover the plaintext of a GRP_TXT packet with a set of known channels.
Does the same decode that the firmware does for an overheard channel text packet.
First, the MAC confirms the channel (:func:`identify_channel`), because a fingerprint
alone can collide. Only the key that the MAC confirmed is trusted to decrypt anything.
A packet from a channel that is not in ``channels`` gives ``None``. A packet whose
fingerprint matches but whose MAC does not match (a true collision) also gives ``None``.
This is the same result as firmware that does not know the channel.
Args:
chan_hash: The channel-hash fingerprint of the packet (2 hex characters).
cipher_mac: The MAC of the packet, encoded as hex (2 bytes).
crypted: The ciphertext of the packet, encoded as hex (a whole number of AES
blocks).
channels: The candidate channels to try, as ``(name, secret)`` pairs.
Returns:
The decrypted text and its channel. ``None`` if the MAC of no known channel
matches, or if the ciphertext is malformed.
"""
from Crypto.Cipher import AES # deferred: refer to the module header
try:
msg = bytes.fromhex(crypted)
except ValueError:
return None
if not msg or len(msg) % AES.block_size:
return None # not a whole number of blocks: not a GRP_TXT body to decrypt
identified = identify_channel(chan_hash, cipher_mac, crypted, channels)
if identified is None:
return None
name, key = identified
plain = AES.new(key, AES.MODE_ECB).decrypt(msg)
timestamp = int.from_bytes(plain[0:4], "little")
attempt = plain[4] & 0x03
text = plain[5:].strip(b"\x00").decode("utf-8", "ignore")
sent_at = None
if timestamp:
try:
sent_at = datetime.fromtimestamp(timestamp, tz=timezone.utc)
except (OverflowError, OSError, ValueError):
sent_at = None
return DecryptedText(channel_name=name, text=text, sent_at=sent_at, attempt=attempt)