fix(anon-req): encode the reply path with its hash mode and hop order

An anon request tells the server how to route its answer back. The leading
byte of that reply path packs two fields, which the server unpacks as:

    reply_path_len       = byte & 63
    reply_path_hash_size = (byte >> 6) + 1

Three defects in producing it:

1. The hash mode was never written into the top two bits, so the server always
   read a hash size of 1 whatever the contact's real mode was.
2. The path was reversed byte-wise (out_path[::-1]) rather than hop-wise. A
   return path visits the same hops in reverse order with each hop's
   multi-byte hash intact.
3. reader.py built out_path by stripping every NUL from the fixed 64-byte
   field. That trims the padding but also eats a legitimate 0x00 inside a hop
   hash, shortening the path and shifting every hop after it. It now takes
   out_path_len * hash_size bytes, as PATH_DISCOVERY_RESPONSE already did.

Worked example at hash mode 2 (3 bytes per hop), for a contact two hops away
via aabbcc then ddeeff:

    before:  lenbyte 0x02, path ffeeddccbbaa
             -> server reads 2 hops of 1 byte, replies via ['ff', 'ee']
    after:   lenbyte 0x82, path ddeeffaabbcc
             -> server reads 2 hops of 3 bytes, replies via ['ddeeff', 'aabbcc']

The old form routes the response to hops that do not exist, so it is dropped
and the request times out.

At hash mode 0 both encodings are byte-identical -- the mode contributes
nothing to the high bits and byte-wise reversal equals hop-wise reversal for
single-byte hops -- which is why this stayed latent: mode 0 is the default.
Confirmed by the mode-0 and zero-hop tests passing unchanged against the old
code while the mode-1/mode-2 tests fail.

Scope: only anon requests routed direct to a contact with a known multi-hop
path. Flood requests are unaffected (the server answers via createPathReturn
and ignores the supplied reply path), as is login (handleLoginReq never sets
reply_path_len, so its reply always goes out flood). The neighbors zero-hop
probe is unaffected: length 0 makes hash size irrelevant.

Encoding is extracted into encode_reply_path() so it can be tested directly.
Verified on hardware only for the zero-hop case, which still works; the
multi-hop paths are covered by unit tests, as the test radio has no multi-hop
contacts to exercise on air.
This commit is contained in:
agessaman
2026-07-26 19:38:33 -07:00
parent 00135bbb95
commit 30446ed093
3 changed files with 169 additions and 5 deletions
+39 -4
View File
@@ -57,6 +57,38 @@ def _validate_destination(dst: DestinationType, prefix_length: int = 6) -> bytes
)
def encode_reply_path(out_path_len: int, out_path_hex: str, out_path_hash_mode: int) -> bytes:
"""Encode the reply path a server should use when answering us.
The leading byte packs two fields, which the server unpacks as:
reply_path_len = byte & 63
reply_path_hash_size = (byte >> 6) + 1
so the hash mode has to travel in the top two bits. Omitting it makes the
server read a hash size of 1 regardless of the real mode, take the wrong
number of bytes per hop, and route its reply to hops that do not exist.
The path itself is reversed by *hop*, not by byte: a return path visits the
same hops in the opposite order, and each hop's multi-byte hash must stay
intact. (At hash mode 0 the two are indistinguishable, which is why this
went unnoticed - mode 0 is the default.)
"""
hash_mode = max(out_path_hash_mode, 0) # -1 means "flood", i.e. no path
hash_size = hash_mode + 1
hops = max(out_path_len, 0) & 63
raw = bytes.fromhex(out_path_hex or "")
# Never read past what the contact actually carries; a truncated or padded
# field would otherwise yield short trailing hops.
hops = min(hops, len(raw) // hash_size)
path = b"".join(
raw[i * hash_size:(i + 1) * hash_size] for i in range(hops - 1, -1, -1)
)
return bytes([hops | (hash_mode << 6)]) + path
class CommandHandlerBase:
"""Base class for command handlers.
@@ -325,7 +357,7 @@ class CommandHandlerBase:
if contact is None:
logger.debug("No contact found, requesting a zero-hop direct reply path")
out_path_len = 0
out_path = b""
reply_path = encode_reply_path(0, "", 0)
else:
if contact["out_path_len"] == -1:
logger.info("No path set trying zero hop")
@@ -339,10 +371,13 @@ class CommandHandlerBase:
# zero-hop on the device. Zero is the right value to send regardless,
# since zero-hop is exactly what we just asked for.
out_path_len = max(contact["out_path_len"], 0)
out_path = bytes.fromhex(contact["out_path"])[::-1]
reply_path = encode_reply_path(
out_path_len,
contact["out_path"],
contact.get("out_path_hash_mode", 0),
)
data = out_path_len.to_bytes(1, "little") + out_path
data = b"\x39" + dst_bytes + request_type.value.to_bytes(1, "little", signed=False) + (data if data else b"")
data = b"\x39" + dst_bytes + request_type.value.to_bytes(1, "little", signed=False) + reply_path
result = await self.send(data, [EventType.MSG_SENT, EventType.ERROR])
+11 -1
View File
@@ -112,7 +112,17 @@ class MessageReader:
else:
c["out_path_hash_mode"] = plen >> 6
c["out_path_len"] = plen & 0x3F # 6 LSB
c["out_path"] = dbuf.read(64).replace(b"\0", b"").hex()
# The field is a fixed 64 bytes, NUL-padded past the real path.
# Take exactly the bytes the path occupies rather than stripping
# NULs: a hop hash may legitimately contain 0x00, and dropping
# those shortens the path and shifts every hop after it.
# (PATH_DISCOVERY_RESPONSE below already reads opl*opl_hlen.)
path_bytes = dbuf.read(64)
if c["out_path_len"] > 0:
used = c["out_path_len"] * (c["out_path_hash_mode"] + 1)
c["out_path"] = path_bytes[:used].hex()
else:
c["out_path"] = ""
c["adv_name"] = dbuf.read(32).decode("utf-8", "ignore").replace("\0", "")
c["last_advert"] = int.from_bytes(dbuf.read(4), byteorder="little")
c["adv_lat"] = (