import struct import time from types import SimpleNamespace from unittest.mock import AsyncMock, MagicMock, patch import pytest from openhop_core.node.handlers.result import HandlerResult from openhop_core.protocol import Identity, LocalIdentity from openhop_core.protocol.packet_builder import PacketBuilder from repeater.handler_helpers.acl import ACL, PERM_ACL_ADMIN, ClientInfo from repeater.handler_helpers.path import PathHelper from repeater.handler_helpers.protocol_request import ProtocolRequestHelper from repeater.handler_helpers.text import TextHelper # --------------------------------------------------------------------------- # Real-crypto collision scaffolding (BUG-053 "try all local candidates"). # # A one-byte destination hash can collide: a packet addressed to a remote node # (or a forged packet) can share its first public-key byte with a local # identity. These helpers build genuinely-encrypted packets so we can prove the # repeater consumes only when a local identity actually decrypts, and otherwise # forwards. # --------------------------------------------------------------------------- TXT_TYPE_CLI_DATA = 1 # no delivery ACK -> deterministic (no background ACK task) def _distinct_identities(n=3): """Return `n` identities whose public keys start with distinct bytes.""" ids, seen = [], set() while len(ids) < n: idn = LocalIdentity() first = idn.get_public_key()[0] if first in seen: continue seen.add(first) ids.append(idn) return ids class _SendDest: """Minimal contact object accepted by PacketBuilder as a send destination.""" def __init__(self, pubkey: bytes): self.public_key = pubkey.hex() self.out_path = [] self.out_path_len = -1 def _force_dest_hash(packet, hash_byte: int): """Rewrite the on-air one-byte dest hash to simulate a prefix collision.""" packet.payload = bytearray(packet.payload) packet.payload[0] = hash_byte return packet def _acl_with_client(sender, local_identity=None): """ACL holding `sender` as an admin client. When `local_identity` is given, the real shared secret is precomputed — the protocol-request handler reads `client.shared_secret` directly. """ acl = ACL() sender_pub = sender.get_public_key() client = ClientInfo(Identity(sender_pub), permissions=PERM_ACL_ADMIN) if local_identity is not None: client.shared_secret = Identity(sender_pub).calc_shared_secret( local_identity.get_private_key() ) acl.clients[sender_pub] = client return acl class _FakeId: def __init__(self, pubkey: bytes): self._pubkey = pubkey def get_public_key(self): return self._pubkey class _FakeClient: def __init__(self, pubkey: bytes, shared_secret: bytes, permissions=0): self.id = _FakeId(pubkey) self.shared_secret = shared_secret self.permissions = permissions self.out_path = bytearray() self.out_path_len = -1 class _FakeACL: def __init__(self, clients): self._clients = list(clients) def get_all_clients(self): return self._clients class _PathPacket: def __init__(self, payload: bytes): self.payload = bytearray(payload) self.mark_do_not_retransmit = MagicMock() class _ReqPacket: def __init__(self, payload: bytes): self.payload = bytearray(payload) self.mark_do_not_retransmit = MagicMock() @pytest.mark.asyncio async def test_path_helper_updates_client_out_path_on_valid_decrypt(): client = _FakeClient(pubkey=bytes([0x22]) + b"x" * 31, shared_secret=b"k" * 32) acl = _FakeACL([client]) helper = PathHelper(acl_dict={0x11: acl}) # Payload: dest(0x11), src(0x22), mac+data... packet = _PathPacket(payload=b"\x11\x22\xaa\xbb\xcc") with patch( "openhop_core.protocol.crypto.CryptoUtils.mac_then_decrypt", return_value=b"\x02\x99\x88\x01", ): handled = await helper.process_path_packet(packet) assert handled is True packet.mark_do_not_retransmit.assert_called_once_with() assert client.out_path_len == 2 assert bytes(client.out_path) == b"\x99\x88" assert isinstance(client.last_activity, int) @pytest.mark.asyncio async def test_path_helper_registers_embedded_ack(): """Firmware path returns embed the delivery ACK after the path (extra_type=PAYLOAD_TYPE_ACK + 4-byte CRC); it must reach ack_received_callback so local waiters (e.g. room server pushes) resolve.""" client = _FakeClient(pubkey=bytes([0x22]) + b"x" * 31, shared_secret=b"k" * 32) acl = _FakeACL([client]) ack_fn = AsyncMock() helper = PathHelper(acl_dict={0x11: acl}, ack_received_callback=ack_fn) packet = _PathPacket(payload=b"\x11\x22\xaa\xbb\xcc") # path_len(2) + path + extra_type(PAYLOAD_TYPE_ACK=3) + crc(4, LE) decrypted = b"\x02\x99\x88" + bytes([0x03]) + bytes.fromhex("4dabaf95") with patch( "openhop_core.protocol.crypto.CryptoUtils.mac_then_decrypt", return_value=decrypted, ): await helper.process_path_packet(packet) ack_fn.assert_awaited_once_with(0x95AFAB4D) assert client.out_path_len == 2 # path update still applied @pytest.mark.asyncio async def test_path_helper_handles_encoded_path_len_with_embedded_ack(): """path_len in a path return is the ENCODED wire byte: with 3-byte hashes an empty path is 0x80, not 0x00. Reading it as a raw count (128) made the helper bail as 'truncated' before the embedded ACK was registered, so room-server pushes to same-instance companions timed out forever. Bytes below are a decrypted path return captured from a live mesh.""" client = _FakeClient(pubkey=bytes([0x22]) + b"x" * 31, shared_secret=b"k" * 32) acl = _FakeACL([client]) ack_fn = AsyncMock() helper = PathHelper(acl_dict={0x11: acl}, ack_received_callback=ack_fn) packet = _PathPacket(payload=b"\x11\x22\xaa\xbb\xcc") # path_len 0x80 (3-byte hashes, 0 hops) + extra_type ACK + crc + AES padding decrypted = bytes.fromhex("80038d48208500000000000000000000") with patch( "openhop_core.protocol.crypto.CryptoUtils.mac_then_decrypt", return_value=decrypted, ): await helper.process_path_packet(packet) ack_fn.assert_awaited_once_with(0x8520488D) assert client.out_path_len == 0x80 # encoded byte preserved assert bytes(client.out_path) == b"" @pytest.mark.asyncio async def test_path_helper_ignores_non_ack_extra(): client = _FakeClient(pubkey=bytes([0x22]) + b"x" * 31, shared_secret=b"k" * 32) acl = _FakeACL([client]) ack_fn = AsyncMock() helper = PathHelper(acl_dict={0x11: acl}, ack_received_callback=ack_fn) packet = _PathPacket(payload=b"\x11\x22\xaa\xbb\xcc") # extra_type 0x08 (PATH) instead of ACK: nothing to register decrypted = b"\x02\x99\x88" + bytes([0x08]) + b"\x01\x02\x03\x04" with patch( "openhop_core.protocol.crypto.CryptoUtils.mac_then_decrypt", return_value=decrypted, ): await helper.process_path_packet(packet) ack_fn.assert_not_awaited() @pytest.mark.asyncio async def test_path_helper_returns_false_for_non_matching_or_invalid_inputs(): client = _FakeClient(pubkey=bytes([0x22]) + b"x" * 31, shared_secret=b"k" * 32) acl = _FakeACL([client]) helper = PathHelper(acl_dict={0x11: acl}) assert await helper.process_path_packet(_PathPacket(payload=b"\x11")) is False assert await helper.process_path_packet(_PathPacket(payload=b"\x33\x22\xaa\xbb")) is False no_secret_client = _FakeClient(pubkey=bytes([0x22]) + b"x" * 31, shared_secret=b"") helper_no_secret = PathHelper(acl_dict={0x11: _FakeACL([no_secret_client])}) assert ( await helper_no_secret.process_path_packet(_PathPacket(payload=b"\x11\x22\xaa\xbb")) is False ) with patch("openhop_core.protocol.crypto.CryptoUtils.mac_then_decrypt", return_value=None): assert await helper.process_path_packet(_PathPacket(payload=b"\x11\x22\xaa\xbb")) is False # A valid MAC with an invalid or truncated PATH envelope is not local # ownership; the forwarding engine must remain eligible to handle it. with patch( "openhop_core.protocol.crypto.CryptoUtils.mac_then_decrypt", return_value=b"\x7f\x99\x88\x01", ): invalid_packet = _PathPacket(payload=b"\x11\x22\xaa\xbb\xcc") assert await helper.process_path_packet(invalid_packet) is False invalid_packet.mark_do_not_retransmit.assert_not_called() with patch( "openhop_core.protocol.crypto.CryptoUtils.mac_then_decrypt", return_value=b"\x02\x99", ): truncated_packet = _PathPacket(payload=b"\x11\x22\xaa\xbb\xcc") assert await helper.process_path_packet(truncated_packet) is False truncated_packet.mark_do_not_retransmit.assert_not_called() @pytest.mark.asyncio async def test_protocol_request_process_routes_and_marks_no_retransmit(): injector = AsyncMock(return_value=True) helper = ProtocolRequestHelper(identity_manager=MagicMock(), packet_injector=injector) assert await helper.process_request_packet(_ReqPacket(payload=b"\x01")) is False pkt_unknown = _ReqPacket(payload=b"\x99\x01") assert await helper.process_request_packet(pkt_unknown) is False dest = 0x42 response_packet = object() async def _core_handler(_packet): return HandlerResult.consumed(response_packet) helper.handlers[dest] = {"handler": _core_handler} pkt = _ReqPacket(payload=bytes([dest, 0x01, 0x02])) with patch("repeater.handler_helpers.protocol_request.asyncio.sleep", new_callable=AsyncMock): handled = await helper.process_request_packet(pkt) assert handled is True pkt.mark_do_not_retransmit.assert_called_once() injector.assert_awaited_once_with(response_packet, wait_for_ack=False) @pytest.mark.asyncio async def test_protocol_request_forwards_on_dest_hash_collision(): """A REQ whose dest prefix collides with ours but does not decrypt for a local client must not be consumed, so the engine can still forward it.""" injector = AsyncMock(return_value=True) helper = ProtocolRequestHelper(identity_manager=MagicMock(), packet_injector=injector) dest = 0x42 async def _core_handler(_packet): # MAC failed for every same-hash candidate -> not for us. return HandlerResult.not_for_us() helper.handlers[dest] = {"handler": _core_handler} pkt = _ReqPacket(payload=bytes([dest, 0x01, 0x02])) handled = await helper.process_request_packet(pkt) assert handled is False pkt.mark_do_not_retransmit.assert_not_called() injector.assert_not_awaited() @pytest.mark.asyncio async def test_protocol_request_consumes_authenticated_without_response(): """A REQ that authenticates for us but yields no reply is still consumed.""" injector = AsyncMock(return_value=True) helper = ProtocolRequestHelper(identity_manager=MagicMock(), packet_injector=injector) dest = 0x42 async def _core_handler(_packet): return HandlerResult.consumed() helper.handlers[dest] = {"handler": _core_handler} pkt = _ReqPacket(payload=bytes([dest, 0x01, 0x02])) handled = await helper.process_request_packet(pkt) assert handled is True pkt.mark_do_not_retransmit.assert_called_once() injector.assert_not_awaited() @pytest.mark.asyncio async def test_protocol_request_process_exception_returns_false(): helper = ProtocolRequestHelper(identity_manager=MagicMock(), packet_injector=AsyncMock()) async def _boom(_packet): raise RuntimeError("oops") helper.handlers[0x33] = {"handler": _boom} pkt = _ReqPacket(payload=b"\x33\x01") assert await helper.process_request_packet(pkt) is False def test_protocol_request_handle_get_status_builds_56_byte_payload(): engine = SimpleNamespace( start_time=time.time() - 120, rx_count=7, forwarded_count=5, sent_flood_count=2, sent_direct_count=3, recv_flood_count=4, recv_direct_count=1, direct_dup_count=6, flood_dup_count=8, airtime_mgr=SimpleNamespace(total_airtime_ms=9300, total_rx_airtime_ms=4200), ) radio = SimpleNamespace( get_noise_floor=lambda: -110, get_last_rssi=lambda: -70, get_last_snr=lambda: 2.5, crc_error_count=11, ) helper = ProtocolRequestHelper( identity_manager=MagicMock(), packet_injector=AsyncMock(), radio=radio, engine=engine, ) data = helper._handle_get_status(client=None, timestamp=0, req_data=b"") assert isinstance(data, (bytes, bytearray)) assert len(data) == 56 def test_protocol_request_access_list_admin_and_reserved_rules(): admin = SimpleNamespace(is_admin=lambda: True) not_admin = SimpleNamespace(is_admin=lambda: False) c1 = _FakeClient(pubkey=b"A" * 32, shared_secret=b"k" * 32, permissions=0x02) c2 = _FakeClient(pubkey=b"B" * 32, shared_secret=b"k" * 32, permissions=0x00) acl = _FakeACL([c1, c2]) helper = ProtocolRequestHelper(identity_manager=MagicMock(), packet_injector=AsyncMock()) assert helper._handle_get_access_list(not_admin, 0, b"\x00\x00", acl) is None assert helper._handle_get_access_list(admin, 0, b"\x01\x00", acl) is None out = helper._handle_get_access_list(admin, 0, b"\x00\x00", acl) assert isinstance(out, bytes) # One active entry only: 6-byte key prefix + 1-byte perms assert len(out) == 7 assert out[-1] == 0x02 def test_protocol_request_get_neighbours_sort_and_pagination(): neighbors = { "AA" * 16: { "is_repeater": True, "zero_hop": True, "last_seen": time.time() - 1, "snr": 5.0, }, "BB" * 16: { "is_repeater": True, "zero_hop": True, "last_seen": time.time() - 10, "snr": 1.0, }, "CC" * 16: { "is_repeater": False, "zero_hop": True, "last_seen": time.time() - 1, "snr": 9.0, }, } storage = SimpleNamespace(get_neighbors=lambda: neighbors) helper = ProtocolRequestHelper( identity_manager=MagicMock(), packet_injector=AsyncMock(), neighbor_tracker=SimpleNamespace(storage=storage), ) # version=0, count=2, offset=0, order_by=2(strongest), pubkey_prefix_len=4, random=0 req = bytes([0, 2]) + struct.pack("