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:
10 files changed
+198
-82
No files matched your search
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
File renamed without changes.
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user