Use more faithful packet frame parsing

This commit is contained in:
Jack Kingsman
2026-03-07 22:35:53 -08:00
parent 48dab293ae
commit 34318e4814
11 changed files with 310 additions and 245 deletions
+14 -71
View File
@@ -107,87 +107,30 @@ def extract_payload(raw_packet: bytes) -> bytes | None:
Returns the payload bytes, or None if packet is malformed.
"""
from app.path_utils import decode_path_byte, path_wire_len
from app.path_utils import parse_packet_envelope
if len(raw_packet) < 2:
return None
try:
header = raw_packet[0]
route_type = header & 0x03
offset = 1
# Skip transport codes if present (TRANSPORT_FLOOD=0, TRANSPORT_DIRECT=3)
if route_type in (0x00, 0x03):
if len(raw_packet) < offset + 4:
return None
offset += 4
# Decode packed path byte
if len(raw_packet) < offset + 1:
return None
hop_count, hash_size = decode_path_byte(raw_packet[offset])
offset += 1
# Skip path data
path_bytes = path_wire_len(hop_count, hash_size)
if len(raw_packet) < offset + path_bytes:
return None
offset += path_bytes
# Rest is payload
return raw_packet[offset:]
except (ValueError, IndexError):
return None
envelope = parse_packet_envelope(raw_packet)
return envelope.payload if envelope is not None else None
def parse_packet(raw_packet: bytes) -> PacketInfo | None:
"""Parse a raw packet and extract basic info."""
from app.path_utils import decode_path_byte, path_wire_len
from app.path_utils import parse_packet_envelope
if len(raw_packet) < 2:
envelope = parse_packet_envelope(raw_packet)
if envelope is None:
return None
try:
header = raw_packet[0]
route_type = RouteType(header & 0x03)
payload_type = PayloadType((header >> 2) & 0x0F)
payload_version = (header >> 6) & 0x03
offset = 1
# Skip transport codes if present
if route_type in (RouteType.TRANSPORT_FLOOD, RouteType.TRANSPORT_DIRECT):
if len(raw_packet) < offset + 4:
return None
offset += 4
# Decode packed path byte
if len(raw_packet) < offset + 1:
return None
hop_count, hash_size = decode_path_byte(raw_packet[offset])
offset += 1
# Extract path data
path_byte_len = path_wire_len(hop_count, hash_size)
if len(raw_packet) < offset + path_byte_len:
return None
path = raw_packet[offset : offset + path_byte_len]
offset += path_byte_len
# Rest is payload
payload = raw_packet[offset:]
return PacketInfo(
route_type=route_type,
payload_type=payload_type,
payload_version=payload_version,
path_length=hop_count,
path_hash_size=hash_size,
path=path,
payload=payload,
route_type=RouteType(envelope.route_type),
payload_type=PayloadType(envelope.payload_type),
payload_version=envelope.payload_version,
path_length=envelope.hop_count,
path_hash_size=envelope.hash_size,
path=envelope.path,
payload=envelope.payload,
)
except (ValueError, IndexError):
except ValueError:
return None
+13 -64
View File
@@ -24,7 +24,7 @@ import aiomqtt
import nacl.bindings
from app.fanout.mqtt_base import BaseMqttPublisher
from app.path_utils import split_path_hex
from app.path_utils import parse_packet_envelope, split_path_hex
logger = logging.getLogger(__name__)
@@ -143,41 +143,17 @@ def _calculate_packet_hash(raw_bytes: bytes) -> str:
return "0" * 16
try:
header = raw_bytes[0]
payload_type = (header >> 2) & 0x0F
route_type = header & 0x03
# Transport codes present for TRANSPORT_FLOOD (0) and TRANSPORT_DIRECT (3)
has_transport = route_type in (0x00, 0x03)
offset = 1 # Past header
if has_transport:
offset += 4 # Skip 4 bytes of transport codes
# Read path byte (packed as [hash_mode:2][hop_count:6]).
# Invalid/truncated packets map to zero hash.
if offset >= len(raw_bytes):
envelope = parse_packet_envelope(raw_bytes)
if envelope is None:
return "0" * 16
path_byte = raw_bytes[offset]
offset += 1
hash_mode = (path_byte >> 6) & 0x03
hop_count = path_byte & 0x3F
hash_size = (hash_mode + 1) if hash_mode < 3 else 1
path_wire_len = hop_count * hash_size
# Skip past path to get to payload. Invalid/truncated packets map to zero hash.
if len(raw_bytes) < offset + path_wire_len:
return "0" * 16
payload_start = offset + path_wire_len
payload_data = raw_bytes[payload_start:]
# Hash: payload_type(1 byte) [+ path_byte as uint16_t LE for TRACE] + payload_data
# IMPORTANT: TRACE hash uses the raw wire byte (not decoded hop count) to match firmware.
hash_obj = hashlib.sha256()
hash_obj.update(bytes([payload_type]))
if payload_type == 9: # PAYLOAD_TYPE_TRACE
hash_obj.update(path_byte.to_bytes(2, byteorder="little"))
hash_obj.update(payload_data)
hash_obj.update(bytes([envelope.payload_type]))
if envelope.payload_type == 9: # PAYLOAD_TYPE_TRACE
hash_obj.update(envelope.path_byte.to_bytes(2, byteorder="little"))
hash_obj.update(envelope.payload)
return hash_obj.hexdigest()[:16].upper()
except Exception:
@@ -198,42 +174,15 @@ def _decode_packet_fields(raw_bytes: bytes) -> tuple[str, str, str, list[str], i
payload_type: int | None = None
try:
if len(raw_bytes) < 2:
envelope = parse_packet_envelope(raw_bytes)
if envelope is None or envelope.payload_version != 0:
return route, packet_type, payload_len, path_values, payload_type
header = raw_bytes[0]
payload_version = (header >> 6) & 0x03
if payload_version != 0:
return route, packet_type, payload_len, path_values, payload_type
route_type = header & 0x03
has_transport = route_type in (0x00, 0x03)
offset = 1
if has_transport:
offset += 4
if len(raw_bytes) <= offset:
return route, packet_type, payload_len, path_values, payload_type
path_byte = raw_bytes[offset]
offset += 1
hash_mode = (path_byte >> 6) & 0x03
hop_count = path_byte & 0x3F
hash_size = (hash_mode + 1) if hash_mode < 3 else 1
path_wire_len = hop_count * hash_size
if len(raw_bytes) < offset + path_wire_len:
return route, packet_type, payload_len, path_values, payload_type
path_bytes = raw_bytes[offset : offset + path_wire_len]
offset += path_wire_len
payload_type = (header >> 2) & 0x0F
route = _ROUTE_MAP.get(route_type, "U")
payload_type = envelope.payload_type
route = _ROUTE_MAP.get(envelope.route_type, "U")
packet_type = str(payload_type)
payload_len = str(max(0, len(raw_bytes) - offset))
path_values = split_path_hex(path_bytes.hex(), hop_count)
payload_len = str(len(envelope.payload))
path_values = split_path_hex(envelope.path.hex(), envelope.hop_count)
return route, packet_type, payload_len, path_values, payload_type
except Exception:
+8 -65
View File
@@ -452,43 +452,14 @@ async def _migrate_004_add_payload_hash_column(conn: aiosqlite.Connection) -> No
def _extract_payload_for_hash(raw_packet: bytes) -> bytes | None:
"""
Extract payload from a raw packet for hashing (migration-local copy of decoder logic).
Extract payload from a raw packet for hashing using canonical framing validation.
Returns the payload bytes, or None if packet is malformed.
"""
if len(raw_packet) < 2:
return None
from app.path_utils import parse_packet_envelope
try:
header = raw_packet[0]
route_type = header & 0x03
offset = 1
# Skip transport codes if present (TRANSPORT_FLOOD=0, TRANSPORT_DIRECT=3)
if route_type in (0x00, 0x03):
if len(raw_packet) < offset + 4:
return None
offset += 4
# Get path byte (packed as [hash_mode:2][hop_count:6])
if len(raw_packet) < offset + 1:
return None
path_byte = raw_packet[offset]
offset += 1
hash_mode = (path_byte >> 6) & 0x03
hop_count = path_byte & 0x3F
hash_size = (hash_mode + 1) if hash_mode < 3 else 1
path_wire_len = hop_count * hash_size
# Skip path bytes
if len(raw_packet) < offset + path_wire_len:
return None
offset += path_wire_len
# Rest is payload (may be empty, matching decoder.py behavior)
return raw_packet[offset:]
except (IndexError, ValueError):
return None
envelope = parse_packet_envelope(raw_packet)
return envelope.payload if envelope is not None else None
async def _migrate_005_backfill_payload_hashes(conn: aiosqlite.Connection) -> None:
@@ -638,42 +609,14 @@ async def _migrate_006_replace_path_len_with_path(conn: aiosqlite.Connection) ->
def _extract_path_from_packet(raw_packet: bytes) -> str | None:
"""
Extract path hex string from a raw packet (migration-local copy of decoder logic).
Extract path hex string from a raw packet using canonical framing validation.
Returns the path as a hex string, or None if packet is malformed.
"""
if len(raw_packet) < 2:
return None
from app.path_utils import parse_packet_envelope
try:
header = raw_packet[0]
route_type = header & 0x03
offset = 1
# Skip transport codes if present (TRANSPORT_FLOOD=0, TRANSPORT_DIRECT=3)
if route_type in (0x00, 0x03):
if len(raw_packet) < offset + 4:
return None
offset += 4
# Get path byte (packed as [hash_mode:2][hop_count:6])
if len(raw_packet) < offset + 1:
return None
path_byte = raw_packet[offset]
offset += 1
hash_mode = (path_byte >> 6) & 0x03
hop_count = path_byte & 0x3F
hash_size = (hash_mode + 1) if hash_mode < 3 else 1
path_wire_len = hop_count * hash_size
# Extract path bytes
if len(raw_packet) < offset + path_wire_len:
return None
path_bytes = raw_packet[offset : offset + path_wire_len]
return path_bytes.hex()
except (IndexError, ValueError):
return None
envelope = parse_packet_envelope(raw_packet)
return envelope.path.hex() if envelope is not None else None
async def _migrate_007_backfill_message_paths(conn: aiosqlite.Connection) -> None:
+94
View File
@@ -9,6 +9,27 @@ The path_len wire byte is packed as [hash_mode:2][hop_count:6]:
Mode 3 (hash_size=4) is reserved and rejected.
"""
from dataclasses import dataclass
MAX_PATH_SIZE = 64
@dataclass(frozen=True)
class ParsedPacketEnvelope:
"""Canonical packet framing parse matching MeshCore Packet::readFrom()."""
header: int
route_type: int
payload_type: int
payload_version: int
path_byte: int
hop_count: int
hash_size: int
path_byte_len: int
path: bytes
payload: bytes
payload_offset: int
def decode_path_byte(path_byte: int) -> tuple[int, int]:
"""Decode a packed path byte into (hop_count, hash_size).
@@ -32,6 +53,79 @@ def path_wire_len(hop_count: int, hash_size: int) -> int:
return hop_count * hash_size
def validate_path_byte(path_byte: int) -> tuple[int, int, int]:
"""Validate a packed path byte using firmware-equivalent rules.
Returns:
(hop_count, hash_size, byte_len)
Raises:
ValueError: If the encoding uses reserved mode 3 or exceeds MAX_PATH_SIZE.
"""
hop_count, hash_size = decode_path_byte(path_byte)
byte_len = path_wire_len(hop_count, hash_size)
if byte_len > MAX_PATH_SIZE:
raise ValueError(
f"Invalid path length {byte_len} bytes exceeds MAX_PATH_SIZE={MAX_PATH_SIZE}"
)
return hop_count, hash_size, byte_len
def parse_packet_envelope(raw_packet: bytes) -> ParsedPacketEnvelope | None:
"""Parse packet framing using firmware Packet::readFrom() semantics.
Validation matches the firmware's path checks:
- reserved mode 3 is invalid
- hop_count * hash_size must not exceed MAX_PATH_SIZE
- at least one payload byte must remain after the path
"""
if len(raw_packet) < 2:
return None
try:
header = raw_packet[0]
route_type = header & 0x03
payload_type = (header >> 2) & 0x0F
payload_version = (header >> 6) & 0x03
offset = 1
if route_type in (0x00, 0x03):
if len(raw_packet) < offset + 4:
return None
offset += 4
if len(raw_packet) < offset + 1:
return None
path_byte = raw_packet[offset]
offset += 1
hop_count, hash_size, path_byte_len = validate_path_byte(path_byte)
if len(raw_packet) < offset + path_byte_len:
return None
path = raw_packet[offset : offset + path_byte_len]
offset += path_byte_len
if offset >= len(raw_packet):
return None
return ParsedPacketEnvelope(
header=header,
route_type=route_type,
payload_type=payload_type,
payload_version=payload_version,
path_byte=path_byte,
hop_count=hop_count,
hash_size=hash_size,
path_byte_len=path_byte_len,
path=path,
payload=raw_packet[offset:],
payload_offset=offset,
)
except (IndexError, ValueError):
return None
def split_path_hex(path_hex: str, hop_count: int) -> list[str]:
"""Split a hex path string into per-hop chunks using the known hop count.