Files
ai-bot/orac/meshcore.py
T

584 lines
20 KiB
Python

"""MeshCore packet parsing/building, routing, dedup, node registry, ADVERT, ACK, PATH."""
from __future__ import annotations
import hashlib
import logging
import os
import struct
import threading
import time
from dataclasses import dataclass, field
from orac.constants import (
DEDUP_TTL,
DM_DEDUP_TTL,
MIN_HASH_SIZE,
PENDING_DM_TTL,
RESP_SERVER_LOGIN_OK,
ROUTE_TTL_S,
)
from orac.crypto import (
build_advert_payload,
build_peer_plaintext,
ecdh_shared_secret,
grp_encrypt,
my_hash,
peer_encrypt_plaintext,
raw_peer_encrypt,
verify_advert_signature,
)
log = logging.getLogger("orac")
# ── Route table ──────────────────────────────────────────────────
@dataclass
class RouteEntry:
"""Learned return route with TTL and SNR metadata."""
hops: list[bytes] # closest-relay-first after reversal of inbound path
hash_size: int
learned_at: float = field(default_factory=time.monotonic)
last_snr: float | None = None
_route_table: dict[int, RouteEntry] = {}
_route_lock = threading.Lock()
_RT_NAMES: dict[int, str] = {0: "tflood", 1: "flood", 2: "direct", 3: "tdirect"}
def learn_route(
src_hash: int,
path_hops: list[bytes],
hash_size: int,
snr: float | None = None,
) -> None:
"""Learn a return route from an incoming packet's path.
path_hops is ordered closest-relay-first as received. We reverse it
so our outgoing path traverses the same relays back toward the sender.
Upgrade rule: newer entry wins if its hash_size is not smaller AND
its SNR is not >=3 dB worse than the existing entry's last SNR.
Policy: 1-byte hashes are rejected (see :data:`MIN_HASH_SIZE`). A peer
using 1-byte path encoding simply won't get a cached route — our replies
to them will flood instead (flood packets always use 2-byte hash mode).
"""
if hash_size < MIN_HASH_SIZE:
return
reversed_hops = list(reversed(path_hops))
with _route_lock:
existing = _route_table.get(src_hash)
if existing is not None:
if hash_size < existing.hash_size:
return # never downgrade hash size
if snr is not None and existing.last_snr is not None and snr < existing.last_snr - 3.0:
# New path is ≥3 dB worse; keep current path and just refresh
# last-seen snr for TTL purposes.
existing.learned_at = time.monotonic()
return
_route_table[src_hash] = RouteEntry(
hops=reversed_hops,
hash_size=hash_size,
learned_at=time.monotonic(),
last_snr=snr,
)
def get_route(dest_hash: int) -> tuple[list[bytes], int] | None:
"""Return a learned route if it exists and hasn't expired, else None.
Legacy tuple return preserved so callers that only care about (hops,
hash_size) keep working. Use :func:`get_route_entry` to access SNR.
"""
with _route_lock:
entry = _route_table.get(dest_hash)
if entry is None:
return None
if time.monotonic() - entry.learned_at > ROUTE_TTL_S:
# Expired: lazy-evict so the next learn can re-populate.
del _route_table[dest_hash]
return None
return (list(entry.hops), entry.hash_size)
def get_route_entry(dest_hash: int) -> RouteEntry | None:
"""Return the full RouteEntry if live, else None."""
with _route_lock:
entry = _route_table.get(dest_hash)
if entry is None:
return None
if time.monotonic() - entry.learned_at > ROUTE_TTL_S:
del _route_table[dest_hash]
return None
return entry
def forget_route(dest_hash: int) -> bool:
"""Hard-delete a learned route. Returns True if an entry was removed."""
with _route_lock:
return _route_table.pop(dest_hash, None) is not None
def route_table_size() -> int:
"""Current live route table size (after lazy eviction — call sparingly)."""
with _route_lock:
return len(_route_table)
def route_name(route_type: int) -> str:
"""Human-readable name for a route type."""
return _RT_NAMES.get(route_type, f"rt{route_type}")
# ── Node registry ────────────────────────────────────────────────
# Stored in state module; these helpers bridge to it.
from orac import state as _state_mod # noqa: E402
def register_node(pubkey: bytes, name: str) -> bool:
"""Register a node. Returns True if this is a NEW node."""
return _state_mod.register_node(pubkey, name)
def lookup_node_by_hash(hash_byte: int) -> list[tuple[bytes, str]]:
"""Find all known nodes whose pubkey first byte matches."""
return _state_mod.lookup_node_by_hash(hash_byte)
def node_name(pubkey_hex: str) -> str:
"""Human-readable name for a node, or truncated hex."""
return _state_mod.node_name(pubkey_hex)
# ── Deduplication ────────────────────────────────────────────────
_seen_packets: dict[bytes, float] = {}
_seen_dm_texts: dict[str, float] = {} # "pubkey_hex:text" -> monotonic timestamp
def is_duplicate(payload_type: int, payload: bytes) -> bool:
"""Dedup via 8-byte packet hash (spec Section 16)."""
now = time.monotonic()
if len(_seen_packets) > 500:
expired = [k for k, t in _seen_packets.items() if now - t > DEDUP_TTL]
for k in expired:
del _seen_packets[k]
pkt_hash = hashlib.sha256(bytes([payload_type]) + payload).digest()[:8]
if pkt_hash in _seen_packets and now - _seen_packets[pkt_hash] < DEDUP_TTL:
return True
_seen_packets[pkt_hash] = now
return False
def is_dm_duplicate(peer_pubkey_hex: str, text: str) -> bool:
"""Dedup on decrypted DM text (catches retries with different attempt counters)."""
now = time.monotonic()
if len(_seen_dm_texts) > 200:
expired = [k for k, t in _seen_dm_texts.items() if now - t > DM_DEDUP_TTL]
for k in expired:
del _seen_dm_texts[k]
key = f"{peer_pubkey_hex}:{text}"
if key in _seen_dm_texts and now - _seen_dm_texts[key] < DM_DEDUP_TTL:
return True
_seen_dm_texts[key] = now
return False
# ── Pending DMs from unknown senders ─────────────────────────────
_pending_dms: dict[int, list[tuple[bytes, float]]] = {}
def queue_pending_dm(src_hash: int, raw_payload: bytes) -> bool:
"""Queue a DM from an unknown sender. Returns True if this is the first queued."""
_pending_dms.setdefault(src_hash, []).append((raw_payload, time.monotonic()))
return len(_pending_dms[src_hash]) == 1
def pop_pending_dms(node_hash: int) -> list[tuple[bytes, float]]:
"""Pop and return all pending DMs for a node hash."""
return _pending_dms.pop(node_hash, [])
def has_pending_dms(node_hash: int) -> bool:
"""Check if there are pending DMs for a node hash."""
return bool(_pending_dms.get(node_hash))
def is_pending_expired(timestamp: float) -> bool:
"""Check if a pending DM timestamp is expired."""
return time.monotonic() - timestamp > PENDING_DM_TTL
# ── Packet parsing ───────────────────────────────────────────────
def parse_header_and_path(
packet: bytes,
) -> tuple[int, int, int, int, bytes, list[bytes], int] | None:
"""Parse MeshCore header + path.
Returns (payload_type, route_type, payload_ver, pos, payload, path_hops, hash_size)
or None. path_hops is a list of raw hash bytes for each hop (closest relay first).
"""
if len(packet) < 3:
return None
header = packet[0]
route_type = header & 0x03
payload_type = (header >> 2) & 0x0F
payload_ver = (header >> 6) & 0x03
if payload_ver != 0:
return None
has_tc = route_type in (0, 3)
for skip_tc in [True, False] if has_tc else [False]:
pos = 5 if skip_tc else 1
if pos >= len(packet):
continue
path_len_byte = packet[pos]
pos += 1
hash_size_code = path_len_byte >> 6
if hash_size_code == 3:
continue
hash_size = hash_size_code + 1
hop_count = path_len_byte & 0x3F
path_bytes = hop_count * hash_size
if path_bytes > 64 or pos + path_bytes > len(packet):
continue
hops: list[bytes] = []
for i in range(hop_count):
hops.append(packet[pos + i * hash_size : pos + (i + 1) * hash_size])
pos += path_bytes
payload = packet[pos:]
if payload and len(payload) <= 184:
return payload_type, route_type, payload_ver, pos, payload, hops, hash_size
return None
# ── Packet building ──────────────────────────────────────────────
def _build_routed_packet(payload_type: int, payload: bytes, dest_hash: int) -> bytes:
"""Build a packet using a learned route if available, otherwise flood."""
route = get_route(dest_hash)
if route is not None:
hops, hash_size = route
hash_size_code = hash_size - 1
path_len_byte = (hash_size_code << 6) | (len(hops) & 0x3F)
path_data = b"".join(hops)
header = bytes([(payload_type << 2) | 2]) # direct route_type = 2
return header + bytes([path_len_byte]) + path_data + payload
else:
header = bytes([(payload_type << 2) | 1]) # flood route_type = 1
path_len = bytes([0x40]) # 0 hops, 2-byte hash mode
return header + path_len + payload
def build_grp_packet(channel_payload: bytes) -> bytes:
"""Wrap channel payload in a GRP_TXT flood packet."""
header = bytes([0x15]) # GRP_TXT flood: (5 << 2) | 1
path_len = bytes([0x40]) # 0 hops, 2-byte hash mode
return header + path_len + channel_payload
def build_advert_packet() -> bytes:
"""Build a MeshCore ADVERT packet for this node."""
advert_payload = build_advert_payload()
header = bytes([0x11]) # ADVERT flood: (4 << 2) | 1
path_len = bytes([0x40]) # 0 hops, 2-byte hash mode
return header + path_len + advert_payload
def build_dm_packet(peer_pubkey: bytes, text: str) -> bytes:
"""Build a TXT_MSG DM packet using learned route or flood."""
packet, _ = build_dm_packet_with_plaintext(peer_pubkey, text)
return packet
def build_dm_packet_with_plaintext(
peer_pubkey: bytes,
text: str,
force_flood: bool = False,
ts: int | None = None,
attempt: int = 0,
) -> tuple[bytes, bytes]:
"""Build a TXT_MSG DM packet and return (packet, plaintext).
Plaintext is needed to precompute the expected ACK CRC that the
recipient's firmware will emit back to us.
:param ts: override the plaintext timestamp; defaults to ``int(time.time())``.
Callers that need retry-stable ACK CRCs should pass a fixed ts and
increment ``attempt`` on each retry.
:param attempt: value for the ``txt_type_attempt`` plaintext byte (0-63).
:param force_flood: if True, emit a flood route-type packet regardless of
any learned route in the table.
"""
plaintext = build_peer_plaintext(text, ts=ts, attempt=attempt)
shared_secret = ecdh_shared_secret(peer_pubkey)
mac_ct = peer_encrypt_plaintext(shared_secret, plaintext)
dm_payload = bytes([peer_pubkey[0], my_hash()]) + mac_ct
if force_flood:
header = bytes([(0x02 << 2) | 1]) # flood route_type = 1
path_len = bytes([0x40]) # 0 hops, 2-byte hash mode
packet = header + path_len + dm_payload
else:
packet = _build_routed_packet(0x02, dm_payload, peer_pubkey[0])
return packet, plaintext
def build_ack_packet(ack_crc: bytes, dest_hash: int) -> bytes:
"""Build an ACK packet using learned route or flood."""
return _build_routed_packet(0x03, ack_crc, dest_hash)
def build_multiack_packet(ack_crc: bytes, remaining: int, dest_hash: int) -> bytes:
"""Build a MULTIPART ACK (PAYLOAD_TYPE_MULTIPART = 0x0A).
Upstream `createMultiAck()` (src/Mesh.cpp:570-583 in commit b1ca3d1):
the payload is 5 bytes — ``(remaining << 4) | PAYLOAD_TYPE_ACK(0x03)``
followed by the 4-byte ACK CRC.
MULTIPART ACKs are emitted ONLY on direct-routed returns (see
:func:`Mesh::routeDirectRecvAcks` upstream). Caller should verify a
direct route exists for ``dest_hash`` and skip MULTIPART otherwise.
"""
if len(ack_crc) != 4:
raise ValueError(f"ack_crc must be 4 bytes, got {len(ack_crc)}")
if not 0 <= remaining <= 15:
raise ValueError(f"remaining must fit a nibble (0..15), got {remaining}")
payload = bytes([(remaining << 4) | 0x03]) + ack_crc
return _build_routed_packet(0x0A, payload, dest_hash)
def build_path_return_packet(peer_pubkey: bytes, path_hops: list[bytes], hash_size: int) -> bytes:
"""Build a PATH return (0x08) so the sender can learn a direct route to us.
Rejects 1-byte hash encodings (see :data:`MIN_HASH_SIZE`).
"""
if hash_size < MIN_HASH_SIZE:
raise ValueError(f"refusing to build PATH_RETURN with hash_size={hash_size}")
reversed_hops = list(reversed(path_hops))
hash_size_code = hash_size - 1
path_len_byte = (hash_size_code << 6) | (len(reversed_hops) & 0x3F)
path_data = b"".join(reversed_hops)
# Inner plaintext: path_len + path + 0xFF (dummy extra) + 4 random bytes
inner = bytes([path_len_byte]) + path_data + b"\xff" + os.urandom(4)
shared_secret = ecdh_shared_secret(peer_pubkey)
mac_ct = raw_peer_encrypt(shared_secret, inner)
# Outer: dest_hash + src_hash + mac + ciphertext
path_payload = bytes([peer_pubkey[0], my_hash()]) + mac_ct
return _build_routed_packet(0x08, path_payload, peer_pubkey[0])
def build_login_response_packet(peer_pubkey: bytes) -> bytes:
"""Build a RESPONSE packet (login OK) back to the peer."""
shared_secret = ecdh_shared_secret(peer_pubkey)
# Response payload: timestamp(4) + RESP_SERVER_LOGIN_OK(1) + zero(1) + permissions(2)
reply = struct.pack("<I", int(time.time()))
reply += bytes([RESP_SERVER_LOGIN_OK, 0x00, 0x00, 0xFF])
mac_ct = raw_peer_encrypt(shared_secret, reply)
resp_payload = bytes([peer_pubkey[0], my_hash()]) + mac_ct
return _build_routed_packet(0x01, resp_payload, peer_pubkey[0])
def build_grp_channel_packet(channel_name: str, sender: str, text: str) -> bytes | None:
"""Build a complete GRP_TXT packet for a channel. Returns None if channel unknown."""
from orac.crypto import get_channel_secret
secret = get_channel_secret(channel_name)
if secret is None:
return None
payload = grp_encrypt(secret, sender, text)
return build_grp_packet(payload)
# ── ACK hash computation ────────────────────────────────────────
def compute_ack_hash(plaintext: bytes, sender_pubkey: bytes) -> bytes:
"""Compute 4-byte ACK hash per MeshCore BaseChatMesh.cpp.
Hash = SHA-256(frag1 || sender_pubkey)[0:4]
frag1 = timestamp(4) + txt_type_attempt(1) + text (no null terminator)
"""
text_bytes = plaintext[5:]
null_pos = text_bytes.find(b"\x00")
text_len = null_pos if null_pos >= 0 else len(text_bytes)
frag1 = plaintext[: 5 + text_len]
return hashlib.sha256(frag1 + sender_pubkey).digest()[:4]
# ── GRP_TXT interception ────────────────────────────────────────
def try_decrypt_grp(raw_payload: bytes) -> tuple[str, str] | None:
"""Try to decrypt a GRP_TXT payload. Returns (channel_name, text) or None."""
from orac.crypto import get_channels_by_hash, grp_verify_and_decrypt, parse_grp_plaintext
if len(raw_payload) < 19:
return None
ch = raw_payload[0]
mac_bytes = raw_payload[1:3]
ciphertext = raw_payload[3:]
candidates = get_channels_by_hash(ch)
for chan_name, secret in candidates:
plaintext = grp_verify_and_decrypt(secret, mac_bytes, ciphertext)
if plaintext is not None:
parsed = parse_grp_plaintext(plaintext)
if parsed:
_, text = parsed
return chan_name, text
return None
# ── DM interception ──────────────────────────────────────────────
def try_decrypt_dm(raw_payload: bytes) -> tuple[bytes, str, str, bytes] | None:
"""Try to decrypt a TXT_MSG DM addressed to us.
Returns (sender_pubkey, sender_name, message_text, raw_plaintext) or None.
"""
from orac.crypto import parse_peer_plaintext, peer_verify_and_decrypt
if len(raw_payload) < 20:
return None
dest_hash = raw_payload[0]
src_hash = raw_payload[1]
mac_bytes = raw_payload[2:4]
ciphertext = raw_payload[4:]
log.debug(
"TXT_MSG dest=0x%02x src=0x%02x me=0x%02x [%dB]",
dest_hash,
src_hash,
my_hash(),
len(ciphertext),
)
if dest_hash != my_hash():
return None
candidates = lookup_node_by_hash(src_hash)
if not candidates:
log.warning("DM for us but no known node with hash 0x%02x", src_hash)
return None
for peer_pubkey, peer_name in candidates:
try:
shared_secret = ecdh_shared_secret(peer_pubkey)
plaintext = peer_verify_and_decrypt(shared_secret, mac_bytes, ciphertext)
if plaintext is not None:
text = parse_peer_plaintext(plaintext)
if text:
return peer_pubkey, peer_name, text, plaintext
except Exception:
pk_hex = peer_pubkey.hex()
log.warning("Evicting bad key for %s (%s...)", peer_name, pk_hex[:16])
_state_mod.evict_node(pk_hex)
continue
return None
def try_decrypt_anon_req(
raw_payload: bytes,
) -> tuple[bytes, str, bytes | None] | None:
"""Try to decrypt an ANON_REQ (login).
Returns (sender_pubkey, sender_name, plaintext_or_None) or None.
"""
from orac.crypto import peer_verify_and_decrypt
if len(raw_payload) < 51:
return None
dest_hash = raw_payload[0]
sender_pubkey = raw_payload[1:33]
mac_bytes = raw_payload[33:35]
ciphertext = raw_payload[35:]
log.info(
"ANON_REQ dest=0x%02x sender=%s... [%dB]",
dest_hash,
sender_pubkey.hex()[:16],
len(ciphertext),
)
if dest_hash != my_hash():
return None
# Register sender -- we now have their full pubkey
peer_name = node_name(sender_pubkey.hex())
is_new = register_node(sender_pubkey, peer_name)
if is_new:
log.info(
"Registered new peer from ANON_REQ: %s (hash=0x%02x)",
peer_name,
sender_pubkey[0],
)
try:
shared_secret = ecdh_shared_secret(sender_pubkey)
plaintext = peer_verify_and_decrypt(shared_secret, mac_bytes, ciphertext)
if plaintext is not None:
return sender_pubkey, peer_name, plaintext
else:
log.warning("ANON_REQ MAC mismatch")
except Exception as e:
log.error("ANON_REQ decrypt error: %s", e)
return sender_pubkey, peer_name, None
def try_decode_advert(raw_payload: bytes) -> tuple[bytes, str] | None:
"""Decode and verify an ADVERT payload. Returns (pubkey, name) or None."""
if len(raw_payload) < 100:
return None
pubkey = raw_payload[0:32]
timestamp = raw_payload[32:36]
signature = raw_payload[36:100]
app_data = raw_payload[100:]
if not verify_advert_signature(pubkey, timestamp, app_data, signature):
return None
name = ""
if app_data:
flags = app_data[0]
pos = 1
if flags & 0x10: # has location
pos += 8
if flags & 0x20: # feat1
pos += 2
if flags & 0x40: # feat2
pos += 2
if flags & 0x80 and pos < len(app_data): # has name
name = app_data[pos:].decode("utf-8", errors="replace")
return pubkey, name if name else pubkey.hex()[:8]