feat: reference Meshtastic decoder, validated on real captures
AES-128-CTR (public LongFast key) + minimal protobuf walk. Verified against packets our scanner captured on-air: decoded a live NodeInfo (longName 'Arch Optiplex', shortName 'ARCH') and POSITION packets. Serves as the reference for the on-chip C++ port in src/main.cpp. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
1fcb0f4c59
commit
de4995468d
114
firmware/tools/meshtastic_decode.py
Normal file
114
firmware/tools/meshtastic_decode.py
Normal file
@ -0,0 +1,114 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Reference Meshtastic packet decoder — validates the crypto/parse logic that
|
||||||
|
the firmware (src/main.cpp) reimplements in C++, and is handy for decoding
|
||||||
|
captured hex offline.
|
||||||
|
|
||||||
|
Meshtastic default-channel ("LongFast") packets are AES-128-CTR encrypted with
|
||||||
|
a PUBLIC, hardcoded key (that's why default-channel traffic is not private).
|
||||||
|
The wire format is a 16-byte header + encrypted `Data` protobuf.
|
||||||
|
|
||||||
|
Validated 2026-07-01 against real packets our Phase 1 scanner captured on-air:
|
||||||
|
decoded a live NodeInfo broadcast (longName "Arch Optiplex", shortName "ARCH")
|
||||||
|
and POSITION packets from node !4358d230.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
python3 meshtastic_decode.py <hexpacket> [<hexpacket> ...]
|
||||||
|
"""
|
||||||
|
|
||||||
|
import struct
|
||||||
|
import sys
|
||||||
|
|
||||||
|
from Crypto.Cipher import AES
|
||||||
|
from Crypto.Util import Counter
|
||||||
|
|
||||||
|
# Meshtastic default "LongFast" PSK (public — key index 1, "AQ==").
|
||||||
|
DEFAULT_KEY = bytes.fromhex("d4f1bb3a20290759f0bcffabcf4e6901")
|
||||||
|
|
||||||
|
PORTNUMS = {1: "TEXT_MESSAGE", 3: "POSITION", 4: "NODEINFO", 67: "TELEMETRY"}
|
||||||
|
|
||||||
|
|
||||||
|
def parse_header(b: bytes):
|
||||||
|
dest, sender, pid = struct.unpack_from("<III", b, 0)
|
||||||
|
flags, chan_hash, next_hop, relay = b[12], b[13], b[14], b[15]
|
||||||
|
return {
|
||||||
|
"dest": dest, "sender": sender, "id": pid,
|
||||||
|
"flags": flags, "chan_hash": chan_hash,
|
||||||
|
"hop_limit": flags & 0x07, "next_hop": next_hop, "relay": relay,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def ctr_decrypt(sender: int, pid: int, ciphertext: bytes, key: bytes = DEFAULT_KEY) -> bytes:
|
||||||
|
# CTR nonce block = packetId (8 bytes LE) + fromNode (4 LE) + extraNonce (4 LE = 0)
|
||||||
|
nonce = struct.pack("<QII", pid, sender, 0)
|
||||||
|
ctr = Counter.new(128, initial_value=int.from_bytes(nonce, "big"))
|
||||||
|
return AES.new(key, AES.MODE_CTR, counter=ctr).decrypt(ciphertext)
|
||||||
|
|
||||||
|
|
||||||
|
def _read_varint(b: bytes, i: int):
|
||||||
|
v, shift = 0, 0
|
||||||
|
while True:
|
||||||
|
v |= (b[i] & 0x7F) << shift
|
||||||
|
shift += 7
|
||||||
|
if not (b[i] & 0x80):
|
||||||
|
return v, i + 1
|
||||||
|
i += 1
|
||||||
|
|
||||||
|
|
||||||
|
def parse_fields(b: bytes) -> dict:
|
||||||
|
"""Minimal protobuf field walk -> {field_number: value}."""
|
||||||
|
out, i = {}, 0
|
||||||
|
while i < len(b):
|
||||||
|
tag = b[i]; i += 1
|
||||||
|
field, wt = tag >> 3, tag & 7
|
||||||
|
if wt == 0:
|
||||||
|
v, i = _read_varint(b, i)
|
||||||
|
out[field] = v
|
||||||
|
elif wt == 2:
|
||||||
|
ln, i = _read_varint(b, i)
|
||||||
|
out[field] = b[i:i + ln]; i += ln
|
||||||
|
else: # unsupported wire type for this minimal parser
|
||||||
|
break
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def decode(hexpkt: str) -> dict:
|
||||||
|
b = bytes.fromhex(hexpkt)
|
||||||
|
h = parse_header(b)
|
||||||
|
plain = ctr_decrypt(h["sender"], h["id"], b[16:])
|
||||||
|
data = parse_fields(plain) # Meshtastic Data message
|
||||||
|
portnum = data.get(1)
|
||||||
|
payload = data.get(2, b"")
|
||||||
|
result = {"header": h, "portnum": portnum, "payload": payload}
|
||||||
|
if portnum == 1:
|
||||||
|
result["text"] = payload.decode("utf-8", "replace")
|
||||||
|
elif portnum == 4: # NodeInfo -> User protobuf
|
||||||
|
user = parse_fields(payload)
|
||||||
|
result["user"] = {
|
||||||
|
"id": user.get(1, b"").decode("utf-8", "replace") if isinstance(user.get(1), bytes) else user.get(1),
|
||||||
|
"long_name": user.get(2, b"").decode("utf-8", "replace") if isinstance(user.get(2), bytes) else None,
|
||||||
|
"short_name": user.get(3, b"").decode("utf-8", "replace") if isinstance(user.get(3), bytes) else None,
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def main(argv) -> int:
|
||||||
|
if len(argv) < 2:
|
||||||
|
print(__doc__)
|
||||||
|
return 1
|
||||||
|
for hexpkt in argv[1:]:
|
||||||
|
r = decode(hexpkt)
|
||||||
|
h = r["header"]
|
||||||
|
port = PORTNUMS.get(r["portnum"], r["portnum"])
|
||||||
|
print(f"from=0x{h['sender']:08x} id=0x{h['id']:08x} chan=0x{h['chan_hash']:02x} port={port}")
|
||||||
|
if "text" in r:
|
||||||
|
print(f" TEXT: {r['text']!r}")
|
||||||
|
elif "user" in r:
|
||||||
|
u = r["user"]
|
||||||
|
print(f" NODEINFO: {u['long_name']!r} / {u['short_name']!r} ({u['id']})")
|
||||||
|
else:
|
||||||
|
print(f" payload({len(r['payload'])}B): {r['payload'].hex()}")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main(sys.argv))
|
||||||
Loading…
x
Reference in New Issue
Block a user