From c44f9b1bb471223bf4e353b0e90351bb422ed3ef Mon Sep 17 00:00:00 2001 From: SpudGunMan Date: Wed, 22 Oct 2025 10:42:54 -0700 Subject: [PATCH 1/8] Add Channel Hash Utility to Node class ### Summary - Added a new method `get_channels_with_hash()` to the `Node` class. - This method returns a list of dictionaries, each containing the channel index, role, name, and a hash value derived from the channel name and PSK. - The hash is calculated using the existing `generate_hash` utility, ensuring consistency with other parts of the codebase. - This utility makes it easier to programmatically access channel metadata, including a unique hash, for scripting, debugging, or integration purposes. ### Motivation - The protobuf `Channel` objects do not include a hash value by default. - This addition provides a Pythonic, easy-to-use way to access channel info and a unique hash for each channel, which can be useful for diagnostics, scripting, or UI display. ### Example Usage ```python channels = node.get_channels_with_hash() for ch in channels: print(f"Index {ch['index']}: {ch['role']} name='{ch['name']}' hash={ch['hash']}") ``` ### Impact - No breaking changes; existing APIs and CLI output remain unchanged. - The new method is additive and can be used where needed for enhanced channel introspection. --- meshtastic/node.py | 33 +++++++++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/meshtastic/node.py b/meshtastic/node.py index b77ad9218..f3b4c4771 100644 --- a/meshtastic/node.py +++ b/meshtastic/node.py @@ -20,6 +20,22 @@ logger = logging.getLogger(__name__) +def xor_hash(data: bytes) -> int: + """Simple XOR hash for demonstration (replace with your actual implementation).""" + h = 0 + for b in data: + h ^= b + return h + +def generate_hash(name: str, key_bytes: bytes) -> int: + """Generate the channel number by hashing the channel name and psk bytes.""" + # If key_bytes is the default "AQ==", use the fallback key + if base64.b64encode(key_bytes).decode("utf-8") == "AQ==": + key_bytes = base64.b64decode("1PG7OiApB1nwvP+rz05pAQ==") + h_name = xor_hash(name.encode("utf-8")) + h_key = xor_hash(key_bytes) + return h_name ^ h_key + class Node: """A model of a (local or remote) node in the mesh @@ -1043,3 +1059,20 @@ def ensureSessionKey(self): nodeid = int(self.nodeNum[1:],16) if self.iface._getOrCreateByNum(nodeid).get("adminSessionPassKey") is None: self.requestConfig(admin_pb2.AdminMessage.SESSIONKEY_CONFIG) + + def get_channels_with_hash(self): + """Return a list of dicts with channel info and hash.""" + result = [] + if self.channels: + for c in self.channels: + if c.settings and hasattr(c.settings, "name") and hasattr(c.settings, "psk"): + hash_val = generate_hash(c.settings.name, c.settings.psk) + else: + hash_val = None + result.append({ + "index": c.index, + "role": channel_pb2.Channel.Role.Name(c.role), + "name": c.settings.name if c.settings and hasattr(c.settings, "name") else "", + "hash": hash_val, + }) + return result From d2d9c03bc873c205c33fcdd59d62cb5ab71da982 Mon Sep 17 00:00:00 2001 From: SpudGunMan Date: Wed, 22 Oct 2025 14:19:52 -0700 Subject: [PATCH 2/8] tidy up the autobot --- meshtastic/node.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/meshtastic/node.py b/meshtastic/node.py index f3b4c4771..8f5f44036 100644 --- a/meshtastic/node.py +++ b/meshtastic/node.py @@ -21,11 +21,11 @@ logger = logging.getLogger(__name__) def xor_hash(data: bytes) -> int: - """Simple XOR hash for demonstration (replace with your actual implementation).""" - h = 0 - for b in data: - h ^= b - return h + """Compute an XOR hash from bytes.""" + result = 0 + for char in data: + result ^= char + return result def generate_hash(name: str, key_bytes: bytes) -> int: """Generate the channel number by hashing the channel name and psk bytes.""" From f6f1b748dc394c2cf68995d916e46f67caacc668 Mon Sep 17 00:00:00 2001 From: SpudGunMan Date: Wed, 22 Oct 2025 14:21:46 -0700 Subject: [PATCH 3/8] hashGen fix using from https://raw.githubusercontent.com/pdxlocations/mudp/refs/heads/main/mudp/encryption.py @pdxlocations --- meshtastic/node.py | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/meshtastic/node.py b/meshtastic/node.py index 8f5f44036..718b81f86 100644 --- a/meshtastic/node.py +++ b/meshtastic/node.py @@ -27,14 +27,16 @@ def xor_hash(data: bytes) -> int: result ^= char return result -def generate_hash(name: str, key_bytes: bytes) -> int: - """Generate the channel number by hashing the channel name and psk bytes.""" - # If key_bytes is the default "AQ==", use the fallback key - if base64.b64encode(key_bytes).decode("utf-8") == "AQ==": - key_bytes = base64.b64decode("1PG7OiApB1nwvP+rz05pAQ==") - h_name = xor_hash(name.encode("utf-8")) +def generate_hash(name: str, key: str) -> int: + """generate the channel number by hashing the channel name and psk""" + if key == "AQ==": + key = "1PG7OiApB1nwvP+rz05pAQ==" + replaced_key = key.replace("-", "+").replace("_", "/") + key_bytes = base64.b64decode(replaced_key.encode("utf-8")) + h_name = xor_hash(bytes(name, "utf-8")) h_key = xor_hash(key_bytes) - return h_name ^ h_key + result: int = h_name ^ h_key + return result class Node: """A model of a (local or remote) node in the mesh From 39c9864682a60e8d6729c656a815d8ba43b9a72b Mon Sep 17 00:00:00 2001 From: SpudGunMan Date: Thu, 6 Nov 2025 11:49:57 -0800 Subject: [PATCH 4/8] move and rename hash function this function, xor_hash, and a variable for the default key (as bytes, I think, rather than the base64 version) really all belong in meshtastic.util rather than here. There's multiple forms of hashing in firmware so this should be named to denote that, perhaps channel_hash. If we later want to add the frequency-slot-style hash, better if it's distinguished better from the start. --- meshtastic/node.py | 12 +++--------- meshtastic/util.py | 6 ++++++ 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/meshtastic/node.py b/meshtastic/node.py index 718b81f86..52085813f 100644 --- a/meshtastic/node.py +++ b/meshtastic/node.py @@ -16,25 +16,19 @@ pskToString, stripnl, message_to_json, + channel_hash, ) logger = logging.getLogger(__name__) -def xor_hash(data: bytes) -> int: - """Compute an XOR hash from bytes.""" - result = 0 - for char in data: - result ^= char - return result - def generate_hash(name: str, key: str) -> int: """generate the channel number by hashing the channel name and psk""" if key == "AQ==": key = "1PG7OiApB1nwvP+rz05pAQ==" replaced_key = key.replace("-", "+").replace("_", "/") key_bytes = base64.b64decode(replaced_key.encode("utf-8")) - h_name = xor_hash(bytes(name, "utf-8")) - h_key = xor_hash(key_bytes) + h_name = channel_hash(bytes(name, "utf-8")) + h_key = channel_hash(key_bytes) result: int = h_name ^ h_key return result diff --git a/meshtastic/util.py b/meshtastic/util.py index 243dfe933..de09e5cd0 100644 --- a/meshtastic/util.py +++ b/meshtastic/util.py @@ -365,6 +365,12 @@ def remove_keys_from_dict(keys: Union[Tuple, List, Set], adict: Dict) -> Dict: remove_keys_from_dict(keys, val) return adict +def channel_hash(data: bytes) -> int: + """Compute an XOR hash from bytes for channel evaluation.""" + result = 0 + for char in data: + result ^= char + return def hexstr(barray: bytes) -> str: """Print a string of hex digits""" From 471e3ce145a2f5198f21fd7f3fd745761930c3f3 Mon Sep 17 00:00:00 2001 From: SpudGunMan Date: Thu, 6 Nov 2025 12:00:16 -0800 Subject: [PATCH 5/8] generate_hash to util --- meshtastic/node.py | 13 +------------ meshtastic/util.py | 21 +++++++++++++++++++++ 2 files changed, 22 insertions(+), 12 deletions(-) diff --git a/meshtastic/node.py b/meshtastic/node.py index 52085813f..09e19b004 100644 --- a/meshtastic/node.py +++ b/meshtastic/node.py @@ -16,22 +16,11 @@ pskToString, stripnl, message_to_json, - channel_hash, + generate_hash, ) logger = logging.getLogger(__name__) -def generate_hash(name: str, key: str) -> int: - """generate the channel number by hashing the channel name and psk""" - if key == "AQ==": - key = "1PG7OiApB1nwvP+rz05pAQ==" - replaced_key = key.replace("-", "+").replace("_", "/") - key_bytes = base64.b64decode(replaced_key.encode("utf-8")) - h_name = channel_hash(bytes(name, "utf-8")) - h_key = channel_hash(key_bytes) - result: int = h_name ^ h_key - return result - class Node: """A model of a (local or remote) node in the mesh diff --git a/meshtastic/util.py b/meshtastic/util.py index de09e5cd0..c5f44f174 100644 --- a/meshtastic/util.py +++ b/meshtastic/util.py @@ -372,6 +372,27 @@ def channel_hash(data: bytes) -> int: result ^= char return +def generate_hash(name, key) -> int: + """generate the channel number by hashing the channel name and psk (accepts str or bytes for both)""" + # Handle key as str or bytes + if isinstance(key, bytes): + key = key.decode("utf-8") + if key == "AQ==": + key = "1PG7OiApB1nwvP+rz05pAQ==" + replaced_key = key.replace("-", "+").replace("_", "/") + key_bytes = base64.b64decode(replaced_key.encode("utf-8")) + + # Handle name as str or bytes + if isinstance(name, bytes): + name_bytes = name + else: + name_bytes = name.encode("utf-8") + + h_name = channel_hash(name_bytes) + h_key = channel_hash(key_bytes) + result: int = h_name ^ h_key + return result + def hexstr(barray: bytes) -> str: """Print a string of hex digits""" return ":".join(f"{x:02x}" for x in barray) From a17cfe9d2b317407c05b0c0fa64a01ae004fb15e Mon Sep 17 00:00:00 2001 From: SpudGunMan Date: Thu, 6 Nov 2025 12:01:53 -0800 Subject: [PATCH 6/8] rename to generate_channel_hash --- meshtastic/node.py | 4 ++-- meshtastic/util.py | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/meshtastic/node.py b/meshtastic/node.py index 09e19b004..6f76852a1 100644 --- a/meshtastic/node.py +++ b/meshtastic/node.py @@ -16,7 +16,7 @@ pskToString, stripnl, message_to_json, - generate_hash, + generate_channel_hash, ) logger = logging.getLogger(__name__) @@ -1051,7 +1051,7 @@ def get_channels_with_hash(self): if self.channels: for c in self.channels: if c.settings and hasattr(c.settings, "name") and hasattr(c.settings, "psk"): - hash_val = generate_hash(c.settings.name, c.settings.psk) + hash_val = generate_channel_hash(c.settings.name, c.settings.psk) else: hash_val = None result.append({ diff --git a/meshtastic/util.py b/meshtastic/util.py index c5f44f174..a644195fa 100644 --- a/meshtastic/util.py +++ b/meshtastic/util.py @@ -372,7 +372,7 @@ def channel_hash(data: bytes) -> int: result ^= char return -def generate_hash(name, key) -> int: +def generate_channel_hash(name, key) -> int: """generate the channel number by hashing the channel name and psk (accepts str or bytes for both)""" # Handle key as str or bytes if isinstance(key, bytes): From 0e67ef37aab4c223a10a9a81b141cf7eeb9da2ae Mon Sep 17 00:00:00 2001 From: Ian McEwen Date: Thu, 6 Nov 2025 14:55:41 -0700 Subject: [PATCH 7/8] Return hash from channel_hash function --- meshtastic/util.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/meshtastic/util.py b/meshtastic/util.py index 23fe246b4..55b2a2701 100644 --- a/meshtastic/util.py +++ b/meshtastic/util.py @@ -370,7 +370,7 @@ def channel_hash(data: bytes) -> int: result = 0 for char in data: result ^= char - return + return result def generate_channel_hash(name, key) -> int: """generate the channel number by hashing the channel name and psk (accepts str or bytes for both)""" From ad04c26d131dfd2439b30de7e3ed9e10a82dbdf4 Mon Sep 17 00:00:00 2001 From: Ian McEwen Date: Thu, 6 Nov 2025 15:19:12 -0700 Subject: [PATCH 8/8] split out constant, improve logic some, add tests for channel_hash and generate_channel_hash --- meshtastic/tests/test_util.py | 45 +++++++++++++++++++++++++++++++++++ meshtastic/util.py | 25 ++++++++++--------- 2 files changed, 57 insertions(+), 13 deletions(-) diff --git a/meshtastic/tests/test_util.py b/meshtastic/tests/test_util.py index 651ad7539..916417ae8 100644 --- a/meshtastic/tests/test_util.py +++ b/meshtastic/tests/test_util.py @@ -11,16 +11,19 @@ from meshtastic.supported_device import SupportedDevice from meshtastic.protobuf import mesh_pb2 from meshtastic.util import ( + DEFAULT_KEY, Timeout, active_ports_on_supported_devices, camel_to_snake, catchAndIgnore, + channel_hash, convert_mac_addr, eliminate_duplicate_port, findPorts, fixme, fromPSK, fromStr, + generate_channel_hash, genPSK256, hexstr, ipstr, @@ -670,3 +673,45 @@ def test_shorthex(): assert result == b'\x05' result = fromStr('0xffff') assert result == b'\xff\xff' + +def test_channel_hash_basics(): + "Test the default key and LongFast with channel_hash" + assert channel_hash(DEFAULT_KEY) == 2 + assert channel_hash("LongFast".encode("utf-8")) == 10 + +@given(st.text(min_size=1, max_size=12)) +def test_channel_hash_fuzz(channel_name): + "Test channel_hash with fuzzed channel names, ensuring it produces single-byte values" + hashed = channel_hash(channel_name.encode("utf-8")) + assert 0 <= hashed <= 0xFF + +def test_generate_channel_hash_basics(): + "Test the default key and LongFast/MediumFast with generate_channel_hash" + assert generate_channel_hash("LongFast", "AQ==") == 8 + assert generate_channel_hash("LongFast", bytes([1])) == 8 + assert generate_channel_hash("LongFast", DEFAULT_KEY) == 8 + assert generate_channel_hash("MediumFast", DEFAULT_KEY) == 31 + +@given(st.text(min_size=1, max_size=12)) +def test_generate_channel_hash_fuzz_default_key(channel_name): + "Test generate_channel_hash with fuzzed channel names and the default key, ensuring it produces single-byte values" + hashed = generate_channel_hash(channel_name, DEFAULT_KEY) + assert 0 <= hashed <= 0xFF + +@given(st.text(min_size=1, max_size=12), st.binary(min_size=1, max_size=1)) +def test_generate_channel_hash_fuzz_simple(channel_name, key_bytes): + "Test generate_channel_hash with fuzzed channel names and one-byte keys, ensuring it produces single-byte values" + hashed = generate_channel_hash(channel_name, key_bytes) + assert 0 <= hashed <= 0xFF + +@given(st.text(min_size=1, max_size=12), st.binary(min_size=16, max_size=16)) +def test_generate_channel_hash_fuzz_aes128(channel_name, key_bytes): + "Test generate_channel_hash with fuzzed channel names and 128-bit keys, ensuring it produces single-byte values" + hashed = generate_channel_hash(channel_name, key_bytes) + assert 0 <= hashed <= 0xFF + +@given(st.text(min_size=1, max_size=12), st.binary(min_size=32, max_size=32)) +def test_generate_channel_hash_fuzz_aes256(channel_name, key_bytes): + "Test generate_channel_hash with fuzzed channel names and 256-bit keys, ensuring it produces single-byte values" + hashed = generate_channel_hash(channel_name, key_bytes) + assert 0 <= hashed <= 0xFF diff --git a/meshtastic/util.py b/meshtastic/util.py index 55b2a2701..9b078494a 100644 --- a/meshtastic/util.py +++ b/meshtastic/util.py @@ -40,6 +40,8 @@ logger = logging.getLogger(__name__) +DEFAULT_KEY = base64.b64decode("1PG7OiApB1nwvP+rz05pAQ==".encode("utf-8")) + def quoteBooleans(a_string: str) -> str: """Quote booleans given a string that contains ": true", replace with ": 'true'" (or false) @@ -372,24 +374,21 @@ def channel_hash(data: bytes) -> int: result ^= char return result -def generate_channel_hash(name, key) -> int: +def generate_channel_hash(name: Union[str, bytes], key: Union[str, bytes]) -> int: """generate the channel number by hashing the channel name and psk (accepts str or bytes for both)""" # Handle key as str or bytes - if isinstance(key, bytes): - key = key.decode("utf-8") - if key == "AQ==": - key = "1PG7OiApB1nwvP+rz05pAQ==" - replaced_key = key.replace("-", "+").replace("_", "/") - key_bytes = base64.b64decode(replaced_key.encode("utf-8")) + if isinstance(key, str): + key = base64.b64decode(key.replace("-", "+").replace("_", "/").encode("utf-8")) + + if len(key) == 1: + key = DEFAULT_KEY[:-1] + key # Handle name as str or bytes - if isinstance(name, bytes): - name_bytes = name - else: - name_bytes = name.encode("utf-8") + if isinstance(name, str): + name = name.encode("utf-8") - h_name = channel_hash(name_bytes) - h_key = channel_hash(key_bytes) + h_name = channel_hash(name) + h_key = channel_hash(key) result: int = h_name ^ h_key return result