The radio's RX log has recorded every frame's relay path all along; now a chat message can be asked how it got here. ^P floats a paths window for the picked (or latest) message: one row per logged arrival — time, reception SNR, and the relay chain through the shared compact path widget — so a channel broadcast that reached us three ways reads as three rows over two distinct paths, resends labelled. Channel messages are matched by evidence, not guesswork: overheard GRP_TXT frames decrypt under the channel's own key (fetched from the device when the conversation didn't carry it) and match the message by content, tolerating the "Name: " sender-prefix convention on the wire — our own broadcasts included, since repeaters' rebroadcasts of us are overheard like anything else. Direct frames are ECDH-encrypted, so the view offers the window's TXT_MSG frames matched by time alone and says so plainly. The sender-prefix parser moves to core.channels where the matcher, the transcript, and the dashboard feed now all share it, and the repository gains the windowed packet_frames_between query. Both chat kinds now share one message-pick model: ↑ picks, the pick walks with ↑↓/PgUp/PgDn/^Home and day-jumps with ^PgUp/^PgDn, ^End returns to compose. Enter on a pick replies in a channel (unchanged) and opens the paths in a direct chat, each with its banner cue. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
136 lines
5.5 KiB
Python
136 lines
5.5 KiB
Python
"""Message-paths tests: matching a chat message back to its logged arrivals.
|
|
|
|
Channel arrivals are matched by decrypting overheard GRP_TXT frames with the channel's
|
|
own key (firmware-shaped frames are built here exactly as the packet-viewer tests build
|
|
theirs); direct arrivals are matched by time alone. Everything runs against a real
|
|
temporary repository, so the window query is exercised too.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from datetime import timedelta
|
|
from pathlib import Path
|
|
|
|
from Crypto.Cipher import AES
|
|
from Crypto.Hash import HMAC, SHA256
|
|
|
|
from meshterm.core.channels import channel_hash, derive_secret
|
|
from meshterm.core.models import ChatMessage, Observation, utcnow
|
|
from meshterm.persistence.repository import Repository
|
|
from meshterm.services.message_paths import (
|
|
channel_arrivals,
|
|
direct_frames_near,
|
|
distinct_paths,
|
|
)
|
|
|
|
SECRET = derive_secret("#general")
|
|
|
|
|
|
def _grp_txt_raw(secret: bytes, text: str, *, attempt: int = 0) -> dict:
|
|
"""A firmware-shaped GRP_TXT packet payload for ``text``, encrypted under ``secret``."""
|
|
plain = (0).to_bytes(4, "little") + bytes([attempt]) + text.encode("utf-8")
|
|
plain += b"\x00" * (-len(plain) % 16)
|
|
crypted = AES.new(secret, AES.MODE_ECB).encrypt(plain)
|
|
mac = HMAC.new(secret, digestmod=SHA256)
|
|
mac.update(crypted)
|
|
return {
|
|
"payload_typename": "GRP_TXT",
|
|
"chan_hash": channel_hash(secret),
|
|
"cipher_mac": mac.digest()[:2].hex(),
|
|
"crypted": crypted.hex(),
|
|
}
|
|
|
|
|
|
def _repo(tmp_path: Path) -> tuple[Repository, int]:
|
|
repo = Repository(tmp_path / "test.db")
|
|
run_id = repo.start_run("monitor", {}, None)
|
|
return repo, run_id
|
|
|
|
|
|
def _record_frame(repo: Repository, run_id: int, *, when, path: str, raw: dict, snr=2.0):
|
|
repo.record_observation(
|
|
run_id,
|
|
Observation(node=None, kind="packet", path=path, snr=snr, observed_at=when, raw=raw),
|
|
)
|
|
|
|
|
|
def test_channel_arrivals_match_by_decrypted_content(tmp_path: Path) -> None:
|
|
"""Every overheard copy of a channel message surfaces, each with its own path."""
|
|
repo, run = _repo(tmp_path)
|
|
now = utcnow()
|
|
wire = "Alice: hi mesh"
|
|
_record_frame(repo, run, when=now - timedelta(seconds=5), path="3d63", raw=_grp_txt_raw(SECRET, wire))
|
|
_record_frame(repo, run, when=now - timedelta(seconds=3), path="3d63,a1b2", raw=_grp_txt_raw(SECRET, wire))
|
|
_record_frame(repo, run, when=now - timedelta(seconds=1), path="", raw=_grp_txt_raw(SECRET, "Alice: other"))
|
|
|
|
message = ChatMessage(text=wire, is_channel=True, created_at=now)
|
|
arrivals = channel_arrivals(repo, message, channel_name="#general", secret=SECRET)
|
|
assert [a.hops for a in arrivals] == [("3d63",), ("3d63", "a1b2")]
|
|
assert distinct_paths(arrivals) == 2
|
|
repo.close()
|
|
|
|
|
|
def test_channel_arrivals_tolerate_the_sender_prefix_on_the_wire(tmp_path: Path) -> None:
|
|
"""Our own outbound message (stored as typed) matches its prefixed on-air copies."""
|
|
repo, run = _repo(tmp_path)
|
|
now = utcnow()
|
|
_record_frame(
|
|
repo, run, when=now + timedelta(seconds=2), path="3d63",
|
|
raw=_grp_txt_raw(SECRET, "Homestead: on my way"),
|
|
)
|
|
message = ChatMessage(text="on my way", outbound=True, is_channel=True, created_at=now)
|
|
arrivals = channel_arrivals(repo, message, channel_name="#general", secret=SECRET)
|
|
assert len(arrivals) == 1 and arrivals[0].hops == ("3d63",)
|
|
repo.close()
|
|
|
|
|
|
def test_channel_arrivals_carry_the_resend_counter(tmp_path: Path) -> None:
|
|
"""A decrypted frame's resend counter rides along, telling copies from retries."""
|
|
repo, run = _repo(tmp_path)
|
|
now = utcnow()
|
|
_record_frame(repo, run, when=now, path="", raw=_grp_txt_raw(SECRET, "Alice: hi", attempt=0))
|
|
_record_frame(
|
|
repo, run, when=now + timedelta(seconds=9), path="",
|
|
raw=_grp_txt_raw(SECRET, "Alice: hi", attempt=1),
|
|
)
|
|
message = ChatMessage(text="Alice: hi", is_channel=True, created_at=now)
|
|
arrivals = channel_arrivals(repo, message, channel_name="#general", secret=SECRET)
|
|
assert [a.resend for a in arrivals] == [0, 1]
|
|
assert distinct_paths(arrivals) == 1 # both arrived direct
|
|
repo.close()
|
|
|
|
|
|
def test_channel_arrivals_ignore_frames_outside_the_window(tmp_path: Path) -> None:
|
|
"""A matching frame far outside the message's window is someone else's message."""
|
|
repo, run = _repo(tmp_path)
|
|
now = utcnow()
|
|
_record_frame(
|
|
repo, run, when=now - timedelta(hours=2), path="3d63",
|
|
raw=_grp_txt_raw(SECRET, "Alice: hi"),
|
|
)
|
|
message = ChatMessage(text="Alice: hi", is_channel=True, created_at=now)
|
|
assert channel_arrivals(repo, message, channel_name="#general", secret=SECRET) == []
|
|
repo.close()
|
|
|
|
|
|
def test_direct_frames_match_by_time_only(tmp_path: Path) -> None:
|
|
"""Direct frames are encrypted, so the window's TXT_MSG frames are the evidence."""
|
|
repo, run = _repo(tmp_path)
|
|
now = utcnow()
|
|
_record_frame(
|
|
repo, run, when=now + timedelta(seconds=3), path="3d63",
|
|
raw={"payload_typename": "TXT_MSG"},
|
|
)
|
|
_record_frame( # a channel frame in the window is not direct-message evidence
|
|
repo, run, when=now + timedelta(seconds=4), path="",
|
|
raw=_grp_txt_raw(SECRET, "Alice: hi"),
|
|
)
|
|
_record_frame( # a direct frame far outside the tight window doesn't correlate
|
|
repo, run, when=now + timedelta(minutes=10), path="",
|
|
raw={"payload_typename": "TXT_MSG"},
|
|
)
|
|
message = ChatMessage(text="see you at 8", peer="d4e5", created_at=now)
|
|
arrivals = direct_frames_near(repo, message)
|
|
assert len(arrivals) == 1 and arrivals[0].hops == ("3d63",)
|
|
repo.close()
|