Files
meshcore-bot/modules/meshcore_payload_decode.py
T
agessaman 7e7279875c feat(packet_capture): decode packet payloads to MQTT and log
Add optional payload decoding to the packet capture service. GRP_TXT
channel messages are decrypted (sender/text), ADVERTs are parsed
(name/role/lat-lon), and a nested "decoded" object is attached to each
packet alongside the unchanged raw fields.

- Comprehensive channel key store: bot's configured radio channels plus
  decode_hashtag_channels, [Channels_List], decode_channel_keys, and the
  built-in default Public key.
- Publishing the decoded object to MQTT is off by default and
  configurable per broker via mqttN_include_decoded.
- Configurable packet-log rotation (off/size/time) for historical dumps.

The decoder lives in a standalone, dependency-free module
(modules/meshcore_payload_decode.py) so it can be shared verbatim with
the meshcore-packet-capture project.

Closes #197
Closes #35
2026-07-08 10:49:06 -07:00

339 lines
12 KiB
Python

#!/usr/bin/env python3
"""Standalone MeshCore packet-payload decoder.
Decodes the *application payload* of a MeshCore packet into plain-text /
structured fields: GRP_TXT (channel) message decryption, ADVERT parsing, and
light structured fields for other payload types.
This module is intentionally **self-contained** — it depends only on the Python
standard library plus ``cryptography`` (already a project dependency). It does
NOT import any bot-specific modules so that it can be copied verbatim into the
parent project ``meshcore-packet-capture`` (canonical home), mirroring the
existing ``auth_token.py`` <-> ``packet_capture_utils.py`` lineage.
Key sourcing (which channel keys to try) is the host's responsibility: build a
:class:`ChannelKeyStore` from your own config / database and hand it to
:func:`decode_payload`.
GRP_TXT wire format and crypto follow the reference implementation
https://github.com/michaelhart/meshcore-decoder :
payload = channel_hash(1) + cipher_mac(2) + ciphertext(...)
channel_hash = first byte of SHA256(channel_key_16)
MAC = HMAC_SHA256(key32, ciphertext)[:2], key32 = key16 + 16 zero bytes
cipher = AES-128-ECB, NoPadding, key = key16
plaintext = timestamp(4, LE u32) + flags(1) + text(UTF-8, NUL-terminated),
text usually "sender: message"
"""
from __future__ import annotations
import hashlib
import hmac
import logging
from datetime import datetime, timezone
from typing import Any, Optional
from cryptography.hazmat.backends import default_backend
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
logger = logging.getLogger("meshcore_payload_decode")
# --- Protocol constants (mirror modules/enums.py; kept local for portability) ---
# PayloadType values (header bits 2-5). See modules/enums.py:PayloadType.
PT_REQ = 0x00
PT_RESPONSE = 0x01
PT_TXT_MSG = 0x02
PT_ACK = 0x03
PT_ADVERT = 0x04
PT_GRP_TXT = 0x05
PT_GRP_DATA = 0x06
PT_ANON_REQ = 0x07
PT_PATH = 0x08
PT_TRACE = 0x09
PT_MULTIPART = 0x0A
PT_RAW_CUSTOM = 0x0F
PAYLOAD_TYPE_NAMES = {
PT_REQ: "REQ",
PT_RESPONSE: "RESPONSE",
PT_TXT_MSG: "TXT_MSG",
PT_ACK: "ACK",
PT_ADVERT: "ADVERT",
PT_GRP_TXT: "GRP_TXT",
PT_GRP_DATA: "GRP_DATA",
PT_ANON_REQ: "ANON_REQ",
PT_PATH: "PATH",
PT_TRACE: "TRACE",
PT_MULTIPART: "MULTIPART",
0x0B: "Type11",
0x0C: "Type12",
0x0D: "Type13",
0x0E: "Type14",
PT_RAW_CUSTOM: "RAW_CUSTOM",
}
# Advert flag bits (see modules/enums.py:AdvertFlags / C++ AdvertDataHelpers.h)
ADV_TYPE_MASK = 0x0F
ADV_TYPE_CHAT = 0x01
ADV_TYPE_REPEATER = 0x02
ADV_TYPE_ROOM = 0x03
ADV_TYPE_SENSOR = 0x04
ADV_LATLON_MASK = 0x10
ADV_FEAT1_MASK = 0x20
ADV_FEAT2_MASK = 0x40
ADV_NAME_MASK = 0x80
_ADV_TYPE_NAMES = {
ADV_TYPE_CHAT: "Companion",
ADV_TYPE_REPEATER: "Repeater",
ADV_TYPE_ROOM: "RoomServer",
ADV_TYPE_SENSOR: "Sensor",
}
# The well-known MeshCore default "Public" channel key (base64 izOH6cXN6mrJ5e26oRXNcg==).
# NOTE: this is a fixed constant, NOT the hashtag derivation of "#public"
# (SHA256("#public")[:16] = 8b4b705b... which is different).
DEFAULT_PUBLIC_CHANNEL_KEY = bytes.fromhex("8b3387e9c5cdea6ac9e5edbaa115cd72")
def derive_hashtag_key(name: str) -> bytes:
"""Derive a public/hashtag channel key from its name.
The key is the first 16 bytes of SHA256 of the lowercased ``#name``.
NOTE: This duplicates ``modules/channel_manager.py:generate_hashtag_key`` on
purpose — this module stays dependency-free for portability. If the MeshCore
derivation ever changes, update both (and the shared test vector).
"""
if not name.startswith("#"):
name = "#" + name
return hashlib.sha256(name.lower().encode("utf-8")).digest()[:16]
def channel_hash_for_key(key16: bytes) -> str:
"""Return the 2-hex channel hash (first byte of SHA256(key)) for a channel key."""
return f"{hashlib.sha256(key16).digest()[0]:02x}"
class ChannelKeyStore:
"""Maps a channel hash -> candidate 16-byte keys (handles hash collisions)."""
def __init__(self) -> None:
# channel_hash (2-hex, lower) -> list of (key16, name)
self._by_hash: dict[str, list[tuple[bytes, Optional[str]]]] = {}
def add_secret(self, key16: bytes, name: Optional[str] = None) -> None:
"""Add a raw 16-byte channel key (optionally with a display name)."""
if not key16 or len(key16) != 16:
logger.debug("Ignoring channel key with invalid length: %r", key16)
return
h = channel_hash_for_key(key16)
bucket = self._by_hash.setdefault(h, [])
if any(existing == key16 for existing, _ in bucket):
return # de-dup identical keys
bucket.append((key16, name))
def add_hex(self, key_hex: str, name: Optional[str] = None) -> None:
"""Add a channel key from a 32-char hex string."""
try:
self.add_secret(bytes.fromhex(key_hex.strip()), name)
except ValueError:
logger.debug("Ignoring non-hex channel key: %r", key_hex)
def add_hashtag(self, name: str) -> None:
"""Add a public/hashtag channel by name (key derived from the name)."""
normalized = name if name.startswith("#") else "#" + name
self.add_secret(derive_hashtag_key(name), normalized.lower())
def has(self, channel_hash: str) -> bool:
return channel_hash.lower() in self._by_hash
def keys_for(self, channel_hash: str) -> list[tuple[bytes, Optional[str]]]:
return self._by_hash.get(channel_hash.lower(), [])
def __len__(self) -> int:
return sum(len(v) for v in self._by_hash.values())
def decrypt_group_text(ciphertext: bytes, cipher_mac: bytes, key16: bytes) -> Optional[dict[str, Any]]:
"""Verify+decrypt a GRP_TXT ciphertext with a single channel key.
Returns ``{timestamp, flags, sender, text}`` on success, or ``None`` if the
MAC fails or the plaintext is malformed.
"""
if len(ciphertext) < 16 or len(ciphertext) % 16 != 0:
return None
# MAC: HMAC-SHA256 over ciphertext with 32-byte secret (key16 + 16 zero bytes)
key32 = key16 + b"\x00" * 16
calc_mac = hmac.new(key32, ciphertext, hashlib.sha256).digest()
if not hmac.compare_digest(calc_mac[:2], cipher_mac[:2]):
return None
# Decrypt: AES-128-ECB, no padding
try:
decryptor = Cipher(algorithms.AES(key16), modes.ECB(), backend=default_backend()).decryptor()
plaintext = decryptor.update(ciphertext) + decryptor.finalize()
except Exception as e: # pragma: no cover - defensive
logger.debug("AES decrypt failed: %s", e)
return None
if len(plaintext) < 5:
return None
timestamp = int.from_bytes(plaintext[0:4], "little")
flags = plaintext[4]
text = plaintext[5:].decode("utf-8", errors="ignore")
nul = text.find("\x00")
if nul >= 0:
text = text[:nul]
# Split "sender: message" when the prefix looks like a name
sender: Optional[str] = None
content = text
colon = text.find(": ")
if 0 < colon < 50:
candidate = text[:colon]
if not any(c in candidate for c in ":[]"):
sender = candidate
content = text[colon + 2:]
return {"timestamp": timestamp, "flags": flags, "sender": sender, "text": content}
def _iso_utc(unix_ts: int) -> Optional[str]:
try:
return datetime.fromtimestamp(unix_ts, tz=timezone.utc).isoformat().replace("+00:00", "Z")
except (OverflowError, OSError, ValueError):
return None
def decode_group_text(payload: bytes, key_store: Optional[ChannelKeyStore]) -> dict[str, Any]:
"""Decode (and, if a key matches, decrypt) a GRP_TXT payload."""
if len(payload) < 3:
return {"kind": "GRP_TXT", "decrypted": False, "error": "payload_too_short"}
channel_hash = f"{payload[0]:02x}"
cipher_mac = payload[1:3]
ciphertext = payload[3:]
result: dict[str, Any] = {
"kind": "GRP_TXT",
"channel_hash": channel_hash,
"cipher_mac": cipher_mac.hex(),
"ciphertext_len": len(ciphertext),
"decrypted": False,
}
if key_store and key_store.has(channel_hash):
for key16, name in key_store.keys_for(channel_hash):
decrypted = decrypt_group_text(ciphertext, cipher_mac, key16)
if decrypted:
result["decrypted"] = True
result["channel"] = name
result["sender"] = decrypted["sender"]
result["text"] = decrypted["text"]
result["flags"] = decrypted["flags"]
result["msg_timestamp"] = _iso_utc(decrypted["timestamp"])
break
return result
def parse_advert(payload: bytes) -> dict[str, Any]:
"""Parse an ADVERT payload (port of meshcore-packet-capture parse_advert).
Layout: pub_key(32) + timestamp(4) + signature(64) + app_data(flags + optional
latlon/feat1/feat2/name).
"""
result: dict[str, Any] = {"kind": "ADVERT"}
try:
if len(payload) < 100:
result.update({"advert_parse_ok": False, "advert_error": "payload_too_short_header"})
return result
result.update(
{
"advert_parse_ok": True,
"public_key": payload[0:32].hex(),
"advert_time": int.from_bytes(payload[32:36], "little"),
"signature": payload[36:100].hex(),
}
)
app_data = payload[100:]
if not app_data:
return result
flags_byte = app_data[0]
adv_type = flags_byte & ADV_TYPE_MASK
result["mode"] = _ADV_TYPE_NAMES.get(adv_type, f"Type{adv_type}")
i = 1
if flags_byte & ADV_LATLON_MASK:
if len(app_data) < i + 8:
return result
lat = int.from_bytes(app_data[i:i + 4], "little", signed=True)
lon = int.from_bytes(app_data[i + 4:i + 8], "little", signed=True)
result["lat"] = round(lat / 1000000.0, 6)
result["lon"] = round(lon / 1000000.0, 6)
i += 8
if flags_byte & ADV_FEAT1_MASK:
if len(app_data) < i + 2:
return result
result["feat1"] = int.from_bytes(app_data[i:i + 2], "little")
i += 2
if flags_byte & ADV_FEAT2_MASK:
if len(app_data) < i + 2:
return result
result["feat2"] = int.from_bytes(app_data[i:i + 2], "little")
i += 2
if flags_byte & ADV_NAME_MASK and len(app_data) > i:
result["name"] = app_data[i:].decode("utf-8", errors="ignore").rstrip("\x00")
return result
except Exception as e: # pragma: no cover - defensive
logger.debug("Error parsing ADVERT: %s", e)
result.update({"advert_parse_ok": False, "advert_error": "exception", "advert_error_detail": str(e)})
return result
def decode_payload(
payload_type_value: int,
payload: bytes,
key_store: Optional[ChannelKeyStore] = None,
) -> dict[str, Any]:
"""Decode a packet's application payload into structured / plain-text fields.
Args:
payload_type_value: PayloadType (header bits 2-5), e.g. 5 for GRP_TXT.
payload: The application payload bytes (after header/transport/path).
key_store: Optional channel keys used to decrypt GRP_TXT messages.
Returns:
A dict describing the decoded payload. Always contains ``kind``.
"""
if payload_type_value == PT_GRP_TXT:
return decode_group_text(payload, key_store)
if payload_type_value == PT_ADVERT:
return parse_advert(payload)
if payload_type_value == PT_TXT_MSG:
# Direct messages are ECDH-encrypted between two nodes; a passive
# observer cannot decrypt them.
return {
"kind": "TXT_MSG",
"encrypted": True,
"note": "direct message; not decryptable by observer",
}
if payload_type_value == PT_ACK:
return {"kind": "ACK", "ack": payload.hex()}
return {"kind": PAYLOAD_TYPE_NAMES.get(payload_type_value, f"Type{payload_type_value}")}