fix: prevent echo mis-correlation for sent channel messages

Pre-compute expected pkt_payloads at send time using channel secret +
timestamp (±3s for clock drift), then match echoes exactly instead of
only checking the 1-byte channel hash. Fixes race condition where an
incoming message's echo on the same channel could be incorrectly
attributed to a just-sent message (wrong Analyzer URL).

Falls back to channel-hash matching when channel secret is unavailable.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
MarekWo
2026-04-14 21:47:07 +02:00
parent 10cc2031d1
commit 77c3ffa5c2
+64 -15
View File
@@ -8,13 +8,17 @@ Event handlers capture incoming data and write to Database + emit SocketIO.
import asyncio import asyncio
import hashlib import hashlib
import hmac as hmac_mod
import json import json
import logging import logging
import struct
import threading import threading
import time import time
from typing import Optional, Any, Dict, List, Tuple from typing import Optional, Any, Dict, List, Tuple
from urllib.parse import urlparse, parse_qs from urllib.parse import urlparse, parse_qs
from Crypto.Cipher import AES
ANALYZER_BASE_URL = 'https://analyzer.letsmesh.net/packets?packet_hash=' ANALYZER_BASE_URL = 'https://analyzer.letsmesh.net/packets?packet_hash='
GRP_TXT_TYPE_BYTE = 0x05 GRP_TXT_TYPE_BYTE = 0x05
@@ -49,6 +53,29 @@ def _to_str(val) -> str:
return str(val) return str(val)
def _compute_pkt_payload(channel_secret_hex, sender_timestamp, txt_type, text, attempt=0):
"""Compute pkt_payload from message data + channel secret.
Reconstructs the encrypted GRP_TXT payload:
channel_hash(1) + HMAC-MAC(2) + AES-128-ECB(plaintext)
where plaintext = timestamp(4 LE) + flags(1) + text(UTF-8) [+ null + zero-pad].
"""
secret = bytes.fromhex(channel_secret_hex)
flags = ((txt_type & 0x3F) << 2) | (attempt & 0x03)
core = struct.pack('<I', sender_timestamp) + bytes([flags]) + text.encode('utf-8')
if len(core) % 16 == 0:
plaintext = core
else:
plaintext = core + b'\x00'
pad_len = (16 - len(plaintext) % 16) % 16
plaintext += b'\x00' * pad_len
cipher = AES.new(secret[:16], AES.MODE_ECB)
ciphertext = cipher.encrypt(plaintext)
mac = hmac_mod.new(secret, ciphertext, hashlib.sha256).digest()[:2]
chan_hash = hashlib.sha256(secret).digest()[0:1]
return (chan_hash + mac + ciphertext).hex()
def parse_meshcore_uri(uri: str) -> Optional[Dict]: def parse_meshcore_uri(uri: str) -> Optional[Dict]:
"""Parse meshcore://contact/add?name=...&public_key=...&type=... URI. """Parse meshcore://contact/add?name=...&public_key=...&type=... URI.
@@ -1075,19 +1102,27 @@ class DeviceManager:
if age > 60: if age > 60:
self._pending_echo = None self._pending_echo = None
elif pe['pkt_payload'] is None: elif pe['pkt_payload'] is None:
# Validate channel hash before correlating — the first byte expected_payloads = pe.get('expected_payloads')
# of pkt_payload is sha256(channel_secret)[0], must match if expected_payloads:
# the channel we sent on to avoid cross-channel mismatches # Exact matching: compare echo against pre-computed payloads
expected_hash = self._get_channel_hash(pe['channel_idx']) if pkt_payload in expected_payloads:
echo_hash = pkt_payload[:2] if pkt_payload else None pe['pkt_payload'] = pkt_payload
if expected_hash and echo_hash and expected_hash == echo_hash: direction = 'sent'
# First echo after send — correlate pkt_payload with sent message self.db.update_message_pkt_payload(pe['msg_id'], pkt_payload)
pe['pkt_payload'] = pkt_payload logger.info(f"Echo: matched pkt_payload with sent msg #{pe['msg_id']}, path={path}")
direction = 'sent' else:
self.db.update_message_pkt_payload(pe['msg_id'], pkt_payload) logger.debug(f"Echo: pkt_payload doesn't match expected candidates — not our sent msg")
logger.info(f"Echo: correlated pkt_payload with sent msg #{pe['msg_id']}, path={path}") else:
elif expected_hash and echo_hash and expected_hash != echo_hash: # Fallback: channel hash matching (no secret available)
logger.debug(f"Echo: channel hash mismatch (expected {expected_hash}, got {echo_hash}) — not our sent msg") expected_hash = self._get_channel_hash(pe['channel_idx'])
echo_hash = pkt_payload[:2] if pkt_payload else None
if expected_hash and echo_hash and expected_hash == echo_hash:
pe['pkt_payload'] = pkt_payload
direction = 'sent'
self.db.update_message_pkt_payload(pe['msg_id'], pkt_payload)
logger.info(f"Echo: correlated pkt_payload with sent msg #{pe['msg_id']} (channel hash fallback), path={path}")
elif expected_hash and echo_hash and expected_hash != echo_hash:
logger.debug(f"Echo: channel hash mismatch (expected {expected_hash}, got {echo_hash}) — not our sent msg")
elif pe['pkt_payload'] == pkt_payload: elif pe['pkt_payload'] == pkt_payload:
# Additional echo for same sent message # Additional echo for same sent message
direction = 'sent' direction = 'sent'
@@ -1309,14 +1344,28 @@ class DeviceManager:
pkt_payload=getattr(event, 'data', {}).get('pkt_payload') if event else None, pkt_payload=getattr(event, 'data', {}).get('pkt_payload') if event else None,
) )
# Register for echo correlation — first RX_LOG_DATA echo will # Pre-compute expected pkt_payloads for echo correlation.
# provide the actual pkt_payload for this sent message # We try ts±3s to account for clock drift between host and firmware.
expected_payloads = set()
secret = self._channel_secrets.get(channel_idx)
if secret and self.device_name:
full_text = f"{self.device_name}: {text}"
for dt in range(-3, 4):
try:
expected_payloads.add(
_compute_pkt_payload(secret, ts + dt, 0, full_text)
)
except Exception:
pass
# Register for echo correlation
with self._echo_lock: with self._echo_lock:
self._pending_echo = { self._pending_echo = {
'timestamp': time.time(), 'timestamp': time.time(),
'channel_idx': channel_idx, 'channel_idx': channel_idx,
'msg_id': msg_id, 'msg_id': msg_id,
'pkt_payload': None, 'pkt_payload': None,
'expected_payloads': expected_payloads or None,
} }
# Emit SocketIO event so sender's UI updates immediately # Emit SocketIO event so sender's UI updates immediately