diff --git a/app/decoder.py b/app/decoder.py index f6ffeea..da9c75e 100644 --- a/app/decoder.py +++ b/app/decoder.py @@ -79,9 +79,11 @@ class PacketInfo: route_type: RouteType payload_type: PayloadType payload_version: int - path_length: int - path: bytes # The routing path (empty if path_length is 0) + path_length: int # Hop count encoded in the lower 6 bits of the path byte + path: bytes # The routing path bytes (empty if path_length is 0) payload: bytes + path_hash_size: int = 1 # Bytes per hop encoded in the upper 2 bits of the path byte + path_byte_length: int = 0 def calculate_channel_hash(channel_key: bytes) -> str: @@ -93,6 +95,14 @@ def calculate_channel_hash(channel_key: bytes) -> str: return format(hash_bytes[0], "02x") +def _decode_path_metadata(path_byte: int) -> tuple[int, int, int]: + """Decode the packed path byte into hop count and byte length.""" + path_hash_size = (path_byte >> 6) + 1 + path_length = path_byte & 0x3F + path_byte_length = path_length * path_hash_size + return path_length, path_hash_size, path_byte_length + + def extract_payload(raw_packet: bytes) -> bytes | None: """ Extract just the payload from a raw packet, skipping header and path. @@ -100,8 +110,10 @@ def extract_payload(raw_packet: bytes) -> bytes | None: Packet structure: - Byte 0: header (route_type, payload_type, version) - For TRANSPORT routes: bytes 1-4 are transport codes - - Next byte: path_length - - Next path_length bytes: path data + - Next byte: packed path metadata + - upper 2 bits: bytes per hop minus 1 + - lower 6 bits: hop count + - Next hop_count * path_hash_size bytes: path data - Remaining: payload Returns the payload bytes, or None if packet is malformed. @@ -120,16 +132,16 @@ def extract_payload(raw_packet: bytes) -> bytes | None: return None offset += 4 - # Get path length + # Decode packed path metadata if len(raw_packet) < offset + 1: return None - path_length = raw_packet[offset] + path_length, _path_hash_size, path_byte_length = _decode_path_metadata(raw_packet[offset]) offset += 1 # Skip path data - if len(raw_packet) < offset + path_length: + if len(raw_packet) < offset + path_byte_length: return None - offset += path_length + offset += path_byte_length # Rest is payload return raw_packet[offset:] @@ -156,17 +168,17 @@ def parse_packet(raw_packet: bytes) -> PacketInfo | None: return None offset += 4 - # Get path length + # Decode packed path metadata if len(raw_packet) < offset + 1: return None - path_length = raw_packet[offset] + path_length, path_hash_size, path_byte_length = _decode_path_metadata(raw_packet[offset]) offset += 1 # Extract path data - if len(raw_packet) < offset + path_length: + if len(raw_packet) < offset + path_byte_length: return None - path = raw_packet[offset : offset + path_length] - offset += path_length + path = raw_packet[offset : offset + path_byte_length] + offset += path_byte_length # Rest is payload payload = raw_packet[offset:] @@ -178,6 +190,8 @@ def parse_packet(raw_packet: bytes) -> PacketInfo | None: path_length=path_length, path=path, payload=payload, + path_hash_size=path_hash_size, + path_byte_length=path_byte_length, ) except (ValueError, IndexError): return None diff --git a/app/event_handlers.py b/app/event_handlers.py index e0cd6e6..65d8f23 100644 --- a/app/event_handlers.py +++ b/app/event_handlers.py @@ -106,13 +106,21 @@ async def on_contact_message(event: "Event") -> None: ts = payload.get("sender_timestamp") sender_timestamp = ts if ts is not None else received_at sender_name = contact.name if contact else None + path = payload.get("path") + payload_path_len = payload.get("path_len") + normalized_path_len = ( + payload_path_len + if isinstance(payload_path_len, int) + else (len(path) // 2 if path is not None else None) + ) msg_id = await MessageRepository.create( msg_type="PRIV", text=payload.get("text", ""), conversation_key=sender_pubkey, sender_timestamp=sender_timestamp, received_at=received_at, - path=payload.get("path"), + path=path, + path_len=normalized_path_len, txt_type=txt_type, signature=payload.get("signature"), sender_key=sender_pubkey, @@ -129,8 +137,11 @@ async def on_contact_message(event: "Event") -> None: logger.debug("DM from %s handled by event handler (fallback path)", sender_pubkey[:12]) # Build paths array for broadcast - path = payload.get("path") - paths = [MessagePath(path=path or "", received_at=received_at)] if path is not None else None + paths = ( + [MessagePath(path=path or "", received_at=received_at, path_len=normalized_path_len)] + if path is not None + else None + ) # Broadcast the new message broadcast_event( diff --git a/app/fanout/apprise_mod.py b/app/fanout/apprise_mod.py index 0cbf31d..514497e 100644 --- a/app/fanout/apprise_mod.py +++ b/app/fanout/apprise_mod.py @@ -56,7 +56,13 @@ def _format_body(data: dict, *, include_path: bool) -> str: if path_str == "": via = " **via:** [`direct`]" else: - hops = [path_str[i : i + 2] for i in range(0, len(path_str), 2)] + path_len = paths[0].get("path_len") if isinstance(paths[0], dict) else None + hop_chars = ( + len(path_str) // path_len + if isinstance(path_len, int) and path_len > 0 and len(path_str) % path_len == 0 + else 2 + ) + hops = [path_str[i : i + hop_chars] for i in range(0, len(path_str), hop_chars)] if hops: hop_list = ", ".join(f"`{h}`" for h in hops) via = f" **via:** [{hop_list}]" diff --git a/app/models.py b/app/models.py index 2fb62a8..727c6ae 100644 --- a/app/models.py +++ b/app/models.py @@ -19,6 +19,26 @@ class Contact(BaseModel): last_read_at: int | None = None # Server-side read state tracking first_seen: int | None = None + @staticmethod + def _derive_out_path_hash_mode(path_hex: str | None, path_len: int) -> int: + """Infer the contact path hash mode from stored path bytes and hop count.""" + if path_len < 0: + return -1 + if path_len == 0 or not path_hex: + return 0 + + if len(path_hex) % 2 != 0: + return 0 + + path_bytes = len(path_hex) // 2 + if path_bytes == 0 or path_bytes % path_len != 0: + return 0 + + bytes_per_hop = path_bytes // path_len + if bytes_per_hop < 1: + return 0 + return bytes_per_hop - 1 + def to_radio_dict(self) -> dict: """Convert to the dict format expected by meshcore radio commands. @@ -32,6 +52,9 @@ class Contact(BaseModel): "flags": self.flags, "out_path": self.last_path or "", "out_path_len": self.last_path_len, + "out_path_hash_mode": self._derive_out_path_hash_mode( + self.last_path, self.last_path_len + ), "adv_lat": self.lat if self.lat is not None else 0.0, "adv_lon": self.lon if self.lon is not None else 0.0, "last_advert": self.last_advert if self.last_advert is not None else 0, @@ -176,8 +199,11 @@ class ChannelDetail(BaseModel): class MessagePath(BaseModel): """A single path that a message took to reach us.""" - path: str = Field(description="Hex-encoded routing path (2 chars per hop)") + path: str = Field(description="Hex-encoded routing path") received_at: int = Field(description="Unix timestamp when this path was received") + path_len: int | None = Field( + default=None, description="Number of hops in the path, when known" + ) class Message(BaseModel): diff --git a/app/packet_processor.py b/app/packet_processor.py index 649bdbe..8b97d01 100644 --- a/app/packet_processor.py +++ b/app/packet_processor.py @@ -57,6 +57,7 @@ async def _handle_duplicate_message( text: str, sender_timestamp: int, path: str | None, + path_len: int | None, received: int, ) -> None: """Handle a duplicate message by updating paths/acks on the existing record. @@ -90,7 +91,9 @@ async def _handle_duplicate_message( # Add path if provided if path is not None: - paths = await MessageRepository.add_path(existing_msg.id, path, received) + paths = await MessageRepository.add_path( + existing_msg.id, path, received, path_len=path_len + ) else: # Get current paths for broadcast paths = existing_msg.paths or [] @@ -128,6 +131,7 @@ async def create_message_from_decrypted( timestamp: int, received_at: int | None = None, path: str | None = None, + path_len: int | None = None, channel_name: str | None = None, realtime: bool = True, ) -> int | None: @@ -150,6 +154,9 @@ async def create_message_from_decrypted( Returns the message ID if created, None if duplicate. """ received = received_at or int(time.time()) + normalized_path_len = ( + path_len if isinstance(path_len, int) else (len(path) // 2 if path is not None else None) + ) # Format the message text with sender prefix if present text = f"{sender}: {message_text}" if sender else message_text @@ -172,6 +179,7 @@ async def create_message_from_decrypted( sender_timestamp=timestamp, received_at=received, path=path, + path_len=normalized_path_len, sender_name=sender, sender_key=resolved_sender_key, ) @@ -182,7 +190,14 @@ async def create_message_from_decrypted( # 2. Same message arrives via multiple paths before first is committed # In either case, add the path to the existing message. await _handle_duplicate_message( - packet_id, "CHAN", channel_key_normalized, text, timestamp, path, received + packet_id, + "CHAN", + channel_key_normalized, + text, + timestamp, + path, + normalized_path_len, + received, ) return None @@ -193,7 +208,11 @@ async def create_message_from_decrypted( # Build paths array for broadcast # Use "is not None" to include empty string (direct/0-hop messages) - paths = [MessagePath(path=path or "", received_at=received)] if path is not None else None + paths = ( + [MessagePath(path=path or "", received_at=received, path_len=normalized_path_len)] + if path is not None + else None + ) # Broadcast new message to connected clients (and fanout modules when realtime) broadcast_event( @@ -223,6 +242,7 @@ async def create_dm_message_from_decrypted( our_public_key: str | None, received_at: int | None = None, path: str | None = None, + path_len: int | None = None, outgoing: bool = False, realtime: bool = True, ) -> int | None: @@ -255,6 +275,9 @@ async def create_dm_message_from_decrypted( return None received = received_at or int(time.time()) + normalized_path_len = ( + path_len if isinstance(path_len, int) else (len(path) // 2 if path is not None else None) + ) # conversation_key is always the other party's public key conversation_key = their_public_key.lower() @@ -270,6 +293,7 @@ async def create_dm_message_from_decrypted( sender_timestamp=decrypted.timestamp, received_at=received, path=path, + path_len=normalized_path_len, outgoing=outgoing, sender_key=conversation_key if not outgoing else None, sender_name=sender_name, @@ -284,6 +308,7 @@ async def create_dm_message_from_decrypted( decrypted.message, decrypted.timestamp, path, + normalized_path_len, received, ) return None @@ -299,7 +324,11 @@ async def create_dm_message_from_decrypted( await RawPacketRepository.mark_decrypted(packet_id, msg_id) # Build paths array for broadcast - paths = [MessagePath(path=path or "", received_at=received)] if path is not None else None + paths = ( + [MessagePath(path=path or "", received_at=received, path_len=normalized_path_len)] + if path is not None + else None + ) # Broadcast new message to connected clients (and fanout modules when realtime) sender_name = contact.name if contact and not outgoing else None @@ -391,6 +420,7 @@ async def run_historical_dm_decryption( our_public_key=our_public_key_bytes.hex(), received_at=packet_timestamp, path=path_hex, + path_len=packet_info.path_length if packet_info else None, outgoing=outgoing, realtime=False, # Historical decryption should not trigger fanout ) @@ -606,6 +636,7 @@ async def _process_group_text( timestamp=decrypted.timestamp, received_at=timestamp, path=packet_info.path.hex() if packet_info else None, + path_len=packet_info.path_length if packet_info else None, ) return { @@ -872,6 +903,7 @@ async def _process_direct_message( our_public_key=our_public_key.hex(), received_at=timestamp, path=packet_info.path.hex() if packet_info else None, + path_len=packet_info.path_length if packet_info else None, outgoing=is_outgoing, ) diff --git a/app/repository/messages.py b/app/repository/messages.py index d3cb977..b08b120 100644 --- a/app/repository/messages.py +++ b/app/repository/messages.py @@ -26,6 +26,7 @@ class MessageRepository: conversation_key: str, sender_timestamp: int | None = None, path: str | None = None, + path_len: int | None = None, txt_type: int = 0, signature: str | None = None, outgoing: bool = False, @@ -43,7 +44,10 @@ class MessageRepository: # Convert single path to paths array format paths_json = None if path is not None: - paths_json = json.dumps([{"path": path, "received_at": received_at}]) + normalized_path_len = path_len if isinstance(path_len, int) else len(path) // 2 + path_entry: dict[str, Any] = {"path": path, "received_at": received_at} + path_entry["path_len"] = normalized_path_len + paths_json = json.dumps([path_entry]) cursor = await db.conn.execute( """ @@ -74,7 +78,7 @@ class MessageRepository: @staticmethod async def add_path( - message_id: int, path: str, received_at: int | None = None + message_id: int, path: str, received_at: int | None = None, path_len: int | None = None ) -> list[MessagePath]: """Add a new path to an existing message. @@ -85,7 +89,10 @@ class MessageRepository: # Atomic append: use json_insert to avoid read-modify-write race when # multiple duplicate packets arrive concurrently for the same message. - new_entry = json.dumps({"path": path, "received_at": ts}) + normalized_path_len = path_len if isinstance(path_len, int) else len(path) // 2 + new_entry_dict: dict[str, Any] = {"path": path, "received_at": ts} + new_entry_dict["path_len"] = normalized_path_len + new_entry = json.dumps(new_entry_dict) await db.conn.execute( """UPDATE messages SET paths = json_insert( COALESCE(paths, '[]'), '$[#]', json(?) diff --git a/app/routers/packets.py b/app/routers/packets.py index b08754b..8142e67 100644 --- a/app/routers/packets.py +++ b/app/routers/packets.py @@ -71,6 +71,7 @@ async def _run_historical_channel_decryption( timestamp=result.timestamp, received_at=packet_timestamp, path=path_hex, + path_len=packet_info.path_length if packet_info else None, realtime=False, # Historical decryption should not trigger fanout ) diff --git a/frontend/src/components/PathModal.tsx b/frontend/src/components/PathModal.tsx index a60320a..727e734 100644 --- a/frontend/src/components/PathModal.tsx +++ b/frontend/src/components/PathModal.tsx @@ -52,7 +52,7 @@ export function PathModal({ const resolvedPaths = hasPaths ? paths.map((p) => ({ ...p, - resolved: resolvePath(p.path, senderInfo, contacts, config), + resolved: resolvePath(p.path, senderInfo, contacts, config, p.path_len), })) : []; @@ -90,7 +90,7 @@ export function PathModal({ {/* Raw path summary */}