fix: MeshCore spec compliance audit and packet quality warnings

Audited against meshcore-spec and fixed all compliance gaps:

- Fix trace hash size encoding: use 1<<(flags&3) not (flags&3)+1
- Reject reserved path hash_size_code 3 per spec Section 3
- Minimum packet size 3 bytes (was 2)
- Add MAX_PACKET_PAYLOAD (184) and min payload (1) validation
- Tighten payload minimums: ANON_REQ≥51, encrypted≥20, group≥19,
  multipart≥2
- Add node_type 0 ("none") to NODE_TYPES
- Fix node_type fallback display (was "node=type=14", now "node=0x0e")

Add packet quality warnings for likely-corrupt frames:
- decode_meshcore_packet returns (str, list[str]) with quality warnings
- ADVERT: flag bad node_type, bad UTF-8 in name, future/bad timestamps
- Combine content warnings with low-SNR signal at display layer
- Warnings stack: one flag = maybe, multiple = high confidence corrupt

Update test corpus and generators for tightened validation rules.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
swaitsandClaude Opus 4.6 committed 2026-04-01 11:57:32 -06:00
1 parent d551095f3f
commit d01de9d14d
10 files changed
+198 -82

No files matched your search

+56 -14
View File
@@ -458,6 +458,24 @@ write_case("trace_basic", {
}
})
# TRACE: flags=2 means hash_size=4 (power of 2 encoding: 1 << 2 = 4)
write_case("trace_4byte_hash", {
"description": "TRACE flood with 4-byte trace hashes (flags=0x02, hash_size=4)",
"tags": ["trace", "flood", "4byte_hash"],
"packet_hex": trace_packet(
0xAABBCCDD, 0x11223344, 0x02, b"\xde\xad\xbe\xef\xca\xfe\xba\xbe"
).hex(),
"valid": True,
"expected": {
"route_type": "flood",
"payload_type": "TRACE",
"payload_ver": 0,
"transport_codes": None,
"path": {"hash_size": 1, "hops": []},
"payload": {"trace_tag": 0xAABBCCDD, "trace_hops": 2}
}
})
# MULTIPART: ACK inner type
write_case("multipart_ack", {
"description": "MULTIPART flood, remaining=3, inner type=ACK",
@@ -513,29 +531,26 @@ write_case("path_3byte_hash", {
}
})
# PATH: 4-byte hash, 1 hop
# PATH: 4-byte hash (hash_size_code 3 = RESERVED, must be rejected)
write_case("path_4byte_hash", {
"description": "PATH flood, 1 hop with 4-byte hash",
"tags": ["path", "flood", "4byte_hash", "encrypted"],
"description": "PATH flood, 4-byte hash (reserved hash_size_code 3, invalid)",
"tags": ["invalid", "path", "flood", "4byte_hash", "reserved_hash_size"],
"packet_hex": peer_packet(
0x08, 1, 0xe6, 0xe9, b"\x2e\x47", bytes(16),
path_hops=[b"\xaa\xbb\xcc\xdd"], hash_size=4
).hex(),
"valid": True,
"valid": False,
"expected": {
"route_type": "flood",
"payload_type": "PATH",
"payload_ver": 0,
"transport_codes": None,
"path": {"hash_size": 4, "hops": ["aabbccdd"]},
"payload": {"dst": "e6", "src": "e9", "mac": "2e47", "ciphertext_len": 16}
"error": "bad_framing"
}
})
# ── Invalid packets ───────────────────────────────────────────────
# Too short: 1 byte
write_case("too_short", {
write_case("too_short_1byte", {
"description": "Packet too short (1 byte)",
"tags": ["invalid", "too_short"],
"packet_hex": "11",
@@ -547,11 +562,24 @@ write_case("too_short", {
}
})
# Bad path_len: claims 33 hops but only 24 bytes remain
bad_pkt = bytes([mc_header(0x02, 1), 0xe1]) + bytes(24)
write_case("bad_path_len", {
"description": "TXT_MSG flood, path_len claims 33 hops (4B hash) but data is too short",
"tags": ["invalid", "bad_framing", "path"],
# Too short: 2 bytes (need at least 3: header + path_len + 1 byte payload)
write_case("too_short_2bytes", {
"description": "Packet too short (2 bytes, need >= 3)",
"tags": ["invalid", "too_short"],
"packet_hex": "1100",
"valid": False,
"expected": {
"route_type": "flood",
"payload_type": "ADVERT",
"error": "too_short"
}
})
# Bad path_len: reserved hash_size_code 3 (bits 6-7 = 0b11)
bad_pkt = bytes([mc_header(0x02, 1), 0xC1]) + bytes(24)
write_case("bad_path_len_reserved_hash_size", {
"description": "TXT_MSG flood, path_len has reserved hash_size_code 3",
"tags": ["invalid", "bad_framing", "path", "reserved_hash_size"],
"packet_hex": bad_pkt.hex(),
"valid": False,
"expected": {
@@ -561,6 +589,20 @@ write_case("bad_path_len", {
}
})
# Bad path_len: claims 33 hops (2B hash) but only 24 bytes remain
bad_pkt2 = bytes([mc_header(0x02, 1), 0x61]) + bytes(24)
write_case("bad_path_len", {
"description": "TXT_MSG flood, path_len claims 33 hops (2B hash) but data is too short",
"tags": ["invalid", "bad_framing", "path"],
"packet_hex": bad_pkt2.hex(),
"valid": False,
"expected": {
"route_type": "flood",
"payload_type": "TXT_MSG",
"error": "bad_framing"
}
})
# Noise: random bytes that don't parse as meshcore
write_case("noise_packet", {
"description": "Random bytes, not a valid MeshCore packet",
+12 -6
View File
@@ -94,7 +94,7 @@ def advert_appdata(draw):
name = data[8 if has_loc else 0:].decode("ascii", errors="replace")
app_data = bytes([flags]) + data
node_label = NODE_TYPES.get(node_type, f"type={node_type}")
node_label = NODE_TYPES.get(node_type, f"0x{node_type:02x}")
return app_data, {"flags": flags, "node_type": node_label, "location": loc, "name": name}
@@ -158,7 +158,7 @@ def valid_encrypted_peer_packet(draw):
dst = draw(st.integers(min_value=0, max_value=255))
src = draw(st.integers(min_value=0, max_value=255))
mac = draw(st.binary(min_size=2, max_size=2))
ct_len = draw(st.integers(min_value=0, max_value=64))
ct_len = draw(st.integers(min_value=16, max_value=64).filter(lambda n: n % 16 == 0))
ct = draw(st.binary(min_size=ct_len, max_size=ct_len))
hdr = bytes([mc_header(ptype, route_type)])
@@ -188,7 +188,7 @@ def valid_group_packet(draw):
hs, hops = draw(path_strategy())
ch = draw(st.integers(min_value=0, max_value=255))
mac = draw(st.binary(min_size=2, max_size=2))
ct_len = draw(st.integers(min_value=0, max_value=64))
ct_len = draw(st.integers(min_value=16, max_value=64).filter(lambda n: n % 16 == 0))
ct = draw(st.binary(min_size=ct_len, max_size=ct_len))
hdr = bytes([mc_header(ptype, route_type)])
@@ -220,7 +220,7 @@ def invalid_packet(draw):
pkt, _ = draw(valid_ack_packet())
cut = draw(st.integers(min_value=0, max_value=max(0, len(pkt) - 2)))
packet = pkt[:cut]
if len(packet) < 2:
if len(packet) < 3:
error = "too_short"
else:
error = "bad_framing"
@@ -230,12 +230,18 @@ def invalid_packet(draw):
ptype = draw(st.sampled_from(list(PAYLOAD_TYPES.keys())))
hdr = bytes([mc_header(ptype, route_type)])
# path_len claiming huge path
bad_pl = draw(st.integers(min_value=0xC0, max_value=0xFF))
# Use hash_size_code 3 (reserved, 0xC0-0xFF) or valid codes with huge counts
bad_pl = draw(st.sampled_from([
*range(0xC0, 0x100), # reserved hash_size_code 3
0x3F, # 63 hops × 1 byte = 63 (valid encoding, will overflow data)
0x60, # 32 hops × 2 bytes = 64 (valid encoding, will overflow data)
0x95, # 21 hops × 3 bytes = 63 (valid encoding, will overflow data)
]))
tail = draw(st.binary(min_size=1, max_size=20))
packet = hdr + bytes([bad_pl]) + tail
error = "bad_framing"
else: # random
packet = draw(st.binary(min_size=2, max_size=50))
packet = draw(st.binary(min_size=3, max_size=50))
error = "not_meshcore"
header = packet[0] if packet else 0
@@ -1,11 +1,11 @@
{
"description": "TXT_MSG flood, path_len claims 33 hops (4B hash) but data is too short",
"description": "TXT_MSG flood, path_len claims 33 hops (2B hash) but data is too short",
"tags": [
"invalid",
"bad_framing",
"path"
],
"packet_hex": "09e1000000000000000000000000000000000000000000000000",
"packet_hex": "0961000000000000000000000000000000000000000000000000",
"valid": false,
"expected": {
"route_type": "flood",
@@ -0,0 +1,16 @@
{
"description": "TXT_MSG flood, path_len has reserved hash_size_code 3",
"tags": [
"invalid",
"bad_framing",
"path",
"reserved_hash_size"
],
"packet_hex": "09c1000000000000000000000000000000000000000000000000",
"valid": false,
"expected": {
"route_type": "flood",
"payload_type": "TXT_MSG",
"error": "bad_framing"
}
}
@@ -1,29 +1,17 @@
{
"description": "PATH flood, 1 hop with 4-byte hash",
"description": "PATH flood, 4-byte hash (reserved hash_size_code 3, invalid)",
"tags": [
"invalid",
"path",
"flood",
"4byte_hash",
"encrypted"
"reserved_hash_size"
],
"packet_hex": "21c1aabbccdde6e92e4700000000000000000000000000000000",
"valid": true,
"valid": false,
"expected": {
"route_type": "flood",
"payload_type": "PATH",
"payload_ver": 0,
"transport_codes": null,
"path": {
"hash_size": 4,
"hops": [
"aabbccdd"
]
},
"payload": {
"dst": "e6",
"src": "e9",
"mac": "2e47",
"ciphertext_len": 16
}
"error": "bad_framing"
}
}
@@ -0,0 +1,14 @@
{
"description": "Packet too short (2 bytes, need >= 3)",
"tags": [
"invalid",
"too_short"
],
"packet_hex": "1100",
"valid": false,
"expected": {
"route_type": "flood",
"payload_type": "ADVERT",
"error": "too_short"
}
}
@@ -0,0 +1,24 @@
{
"description": "TRACE flood with 4-byte trace hashes (flags=0x02, hash_size=4)",
"tags": [
"trace",
"flood",
"4byte_hash"
],
"packet_hex": "2500ddccbbaa4433221102deadbeefcafebabe",
"valid": true,
"expected": {
"route_type": "flood",
"payload_type": "TRACE",
"payload_ver": 0,
"transport_codes": null,
"path": {
"hash_size": 1,
"hops": []
},
"payload": {
"trace_tag": 2864434397,
"trace_hops": 2
}
}
}
+1 -1
View File
@@ -58,7 +58,7 @@ def validate_decode(case: dict, path: Path) -> list[str]:
# Run decoder — must not crash
try:
raw_output = decode_meshcore_packet(packet)
raw_output, _warnings = decode_meshcore_packet(packet)
except Exception as e:
errors.append(f"Decoder crashed: {e}")
return errors