Skip to content

Commit 0bb0410

Browse files
authored
Merge pull request #843 from SpudGunMan/channel-hash-info
Add Channel Hash Utility to Node class
2 parents 87682c1 + ad04c26 commit 0bb0410

File tree

3 files changed

+89
-0
lines changed

3 files changed

+89
-0
lines changed

meshtastic/node.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
pskToString,
1717
stripnl,
1818
message_to_json,
19+
generate_channel_hash,
1920
to_node_num,
2021
)
2122

@@ -1018,3 +1019,20 @@ def ensureSessionKey(self):
10181019
nodeid = to_node_num(self.nodeNum)
10191020
if self.iface._getOrCreateByNum(nodeid).get("adminSessionPassKey") is None:
10201021
self.requestConfig(admin_pb2.AdminMessage.SESSIONKEY_CONFIG)
1022+
1023+
def get_channels_with_hash(self):
1024+
"""Return a list of dicts with channel info and hash."""
1025+
result = []
1026+
if self.channels:
1027+
for c in self.channels:
1028+
if c.settings and hasattr(c.settings, "name") and hasattr(c.settings, "psk"):
1029+
hash_val = generate_channel_hash(c.settings.name, c.settings.psk)
1030+
else:
1031+
hash_val = None
1032+
result.append({
1033+
"index": c.index,
1034+
"role": channel_pb2.Channel.Role.Name(c.role),
1035+
"name": c.settings.name if c.settings and hasattr(c.settings, "name") else "",
1036+
"hash": hash_val,
1037+
})
1038+
return result

meshtastic/tests/test_util.py

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,16 +11,19 @@
1111
from meshtastic.supported_device import SupportedDevice
1212
from meshtastic.protobuf import mesh_pb2
1313
from meshtastic.util import (
14+
DEFAULT_KEY,
1415
Timeout,
1516
active_ports_on_supported_devices,
1617
camel_to_snake,
1718
catchAndIgnore,
19+
channel_hash,
1820
convert_mac_addr,
1921
eliminate_duplicate_port,
2022
findPorts,
2123
fixme,
2224
fromPSK,
2325
fromStr,
26+
generate_channel_hash,
2427
genPSK256,
2528
hexstr,
2629
ipstr,
@@ -670,3 +673,45 @@ def test_shorthex():
670673
assert result == b'\x05'
671674
result = fromStr('0xffff')
672675
assert result == b'\xff\xff'
676+
677+
def test_channel_hash_basics():
678+
"Test the default key and LongFast with channel_hash"
679+
assert channel_hash(DEFAULT_KEY) == 2
680+
assert channel_hash("LongFast".encode("utf-8")) == 10
681+
682+
@given(st.text(min_size=1, max_size=12))
683+
def test_channel_hash_fuzz(channel_name):
684+
"Test channel_hash with fuzzed channel names, ensuring it produces single-byte values"
685+
hashed = channel_hash(channel_name.encode("utf-8"))
686+
assert 0 <= hashed <= 0xFF
687+
688+
def test_generate_channel_hash_basics():
689+
"Test the default key and LongFast/MediumFast with generate_channel_hash"
690+
assert generate_channel_hash("LongFast", "AQ==") == 8
691+
assert generate_channel_hash("LongFast", bytes([1])) == 8
692+
assert generate_channel_hash("LongFast", DEFAULT_KEY) == 8
693+
assert generate_channel_hash("MediumFast", DEFAULT_KEY) == 31
694+
695+
@given(st.text(min_size=1, max_size=12))
696+
def test_generate_channel_hash_fuzz_default_key(channel_name):
697+
"Test generate_channel_hash with fuzzed channel names and the default key, ensuring it produces single-byte values"
698+
hashed = generate_channel_hash(channel_name, DEFAULT_KEY)
699+
assert 0 <= hashed <= 0xFF
700+
701+
@given(st.text(min_size=1, max_size=12), st.binary(min_size=1, max_size=1))
702+
def test_generate_channel_hash_fuzz_simple(channel_name, key_bytes):
703+
"Test generate_channel_hash with fuzzed channel names and one-byte keys, ensuring it produces single-byte values"
704+
hashed = generate_channel_hash(channel_name, key_bytes)
705+
assert 0 <= hashed <= 0xFF
706+
707+
@given(st.text(min_size=1, max_size=12), st.binary(min_size=16, max_size=16))
708+
def test_generate_channel_hash_fuzz_aes128(channel_name, key_bytes):
709+
"Test generate_channel_hash with fuzzed channel names and 128-bit keys, ensuring it produces single-byte values"
710+
hashed = generate_channel_hash(channel_name, key_bytes)
711+
assert 0 <= hashed <= 0xFF
712+
713+
@given(st.text(min_size=1, max_size=12), st.binary(min_size=32, max_size=32))
714+
def test_generate_channel_hash_fuzz_aes256(channel_name, key_bytes):
715+
"Test generate_channel_hash with fuzzed channel names and 256-bit keys, ensuring it produces single-byte values"
716+
hashed = generate_channel_hash(channel_name, key_bytes)
717+
assert 0 <= hashed <= 0xFF

meshtastic/util.py

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,8 @@
4040

4141
logger = logging.getLogger(__name__)
4242

43+
DEFAULT_KEY = base64.b64decode("1PG7OiApB1nwvP+rz05pAQ==".encode("utf-8"))
44+
4345
def quoteBooleans(a_string: str) -> str:
4446
"""Quote booleans
4547
given a string that contains ": true", replace with ": 'true'" (or false)
@@ -365,6 +367,30 @@ def remove_keys_from_dict(keys: Union[Tuple, List, Set], adict: Dict) -> Dict:
365367
remove_keys_from_dict(keys, val)
366368
return adict
367369

370+
def channel_hash(data: bytes) -> int:
371+
"""Compute an XOR hash from bytes for channel evaluation."""
372+
result = 0
373+
for char in data:
374+
result ^= char
375+
return result
376+
377+
def generate_channel_hash(name: Union[str, bytes], key: Union[str, bytes]) -> int:
378+
"""generate the channel number by hashing the channel name and psk (accepts str or bytes for both)"""
379+
# Handle key as str or bytes
380+
if isinstance(key, str):
381+
key = base64.b64decode(key.replace("-", "+").replace("_", "/").encode("utf-8"))
382+
383+
if len(key) == 1:
384+
key = DEFAULT_KEY[:-1] + key
385+
386+
# Handle name as str or bytes
387+
if isinstance(name, str):
388+
name = name.encode("utf-8")
389+
390+
h_name = channel_hash(name)
391+
h_key = channel_hash(key)
392+
result: int = h_name ^ h_key
393+
return result
368394

369395
def hexstr(barray: bytes) -> str:
370396
"""Print a string of hex digits"""

0 commit comments

Comments
 (0)