No, it isn't the ack — ACK is its own payload class and was never in the match. Both symptoms come from the same place: a packet header's route type, which we never recorded, decides what its `path` field *is*. A FLOOD packet accumulates: every relay appends its hash, so the path is the route it travelled to reach us. A DIRECT packet is the opposite — the path is a routing instruction the sender wrote and the relays consume — so it says where the packet is going, and an empty one means the route was used up, not that the packet arrived in a single leap. Reading every path the first way is what drew a message from five hops out as having appeared from nowhere. The evidence is in your own log. The four-hop route on those sends is identical across four consecutive messages, where a flood-accumulated path varies every time; it ends on 40ef0f, which is the first hop of everything hairu sends *us*; and a PATH frame carrying exactly those four hops crosses the log seconds before the first message that uses it. That is a learned route to hairu being announced and then used — not a route anything took to us. So route_typename is stored (schema v15, a `route` column beside the crypto and addressing ones added for the same reason), and an Arrival carries whether it was routed. A routed frame with no path has route_known False: it is kept as a copy that reached us — the count and the time are real — but it is drawn as "routed · route not carried" instead of a confident zero hops, and it puts no lane in the fan, because an empty lane there is a claim of adjacency the frame never made. Frames from before the column read None and fall back to the old behaviour rather than to a guess. The round trip is a second bug and needed no new data. The path line closed on our own ★ unconditionally, and for an outgoing message its head is our ★ too — so every send drew ★ ▶ … ▶ ★. The graph did the same, DST_NODE having been hardcoded to us. Both now take a destination: the far end for a message we sent, ourselves for one we received. It is the rule the line's head already followed — a path runs between the nodes it went between — applied at last to its tail. A channel broadcast keeps us at both ends, because there the copies we log really are our own message coming back. collapse now keys on the route type as well as the hops, so a route travelled and a route intended never fold into one row even when the hashes match. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
348 lines
15 KiB
Python
348 lines
15 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,
|
|
collapse,
|
|
direct_arrivals,
|
|
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 _direct_raw(dest: str = "", src: str = "", mac: str = "", route: str = "") -> dict:
|
|
"""A direct-message frame's raw payload, spelled as the meshcore library reports it."""
|
|
raw: dict = {"payload_typename": "TEXT_MSG"}
|
|
if dest:
|
|
raw["dest_hash"] = dest
|
|
if src:
|
|
raw["src_hash"] = src
|
|
if mac:
|
|
raw["cipher_mac"] = mac
|
|
if route:
|
|
raw["route_typename"] = route
|
|
return raw
|
|
|
|
|
|
def test_direct_frames_without_a_mac_fall_back_to_address_and_time(tmp_path: Path) -> None:
|
|
"""History recorded before the MAC was kept still correlates — and says that it did.
|
|
|
|
The fallback is honest evidence, not a claim: a frame between the right pair in the
|
|
right window is only *probably* this message, and ``exact`` comes back ``False`` so the
|
|
view can bill it that way.
|
|
"""
|
|
repo, run = _repo(tmp_path)
|
|
now = utcnow()
|
|
_record_frame(
|
|
repo, run, when=now + timedelta(seconds=3), path="3d63",
|
|
raw=_direct_raw(dest="d4", src="a1"),
|
|
)
|
|
_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 window doesn't correlate
|
|
repo, run, when=now + timedelta(minutes=10), path="",
|
|
raw=_direct_raw(dest="d4", src="a1"),
|
|
)
|
|
message = ChatMessage(
|
|
text="see you at 8", outbound=True, peer="d4e5", created_at=now
|
|
)
|
|
arrivals, exact = direct_arrivals(
|
|
repo, message, self_key="a1" + "0" * 62, peer_key="d4e5"
|
|
)
|
|
assert [a.hops for a in arrivals] == [("3d63",)]
|
|
assert exact is False
|
|
repo.close()
|
|
|
|
|
|
def test_direct_frames_keep_only_the_direction_the_message_travelled(tmp_path: Path) -> None:
|
|
"""A send shows our outgoing frames; a received message shows the incoming ones.
|
|
|
|
Both ends' hashes sit on every frame of a conversation whichever way it went, so
|
|
matching on the pair alone put our own sends into a received message's view. That is
|
|
where the implausible one-hop rows came from (JP, 2026-09-02): our transmissions heard
|
|
coming back off the repeaters in earshot, correctly one hop, shown as if they were an
|
|
inbound route to us.
|
|
"""
|
|
repo, run = _repo(tmp_path)
|
|
now = utcnow()
|
|
ours = dict(dest="d4", src="a1", mac="beef") # us -> peer
|
|
theirs = dict(dest="a1", src="d4", mac="f00d") # peer -> us
|
|
_record_frame(repo, run, when=now + timedelta(seconds=1), path="3d63",
|
|
raw=_direct_raw(**ours))
|
|
_record_frame(repo, run, when=now + timedelta(seconds=2), path="c0",
|
|
raw=_direct_raw(**theirs))
|
|
# Someone else's traffic, overheard in the same window.
|
|
_record_frame(repo, run, when=now + timedelta(seconds=3), path="3d63",
|
|
raw=_direct_raw(dest="7f", src="c0", mac="dead"))
|
|
_record_frame(repo, run, when=now + timedelta(seconds=4), path="",
|
|
raw=_direct_raw(dest="a1", src="7f", mac="cafe"))
|
|
|
|
sent = ChatMessage(text="see you at 8", outbound=True, peer="d4e5f6a7", created_at=now)
|
|
got = ChatMessage(text="ok", outbound=False, peer="d4e5f6a7", created_at=now)
|
|
keys = dict(self_key="a1" + "0" * 62, peer_key="d4e5f6a7")
|
|
|
|
assert [a.hops for a in direct_arrivals(repo, sent, **keys)[0]] == [("3d63",)]
|
|
assert [a.hops for a in direct_arrivals(repo, got, **keys)[0]] == [("c0",)]
|
|
repo.close()
|
|
|
|
|
|
def test_a_shared_mac_separates_one_message_from_the_next(tmp_path: Path) -> None:
|
|
"""Two sends seconds apart keep their own frames — the MAC is the fingerprint.
|
|
|
|
This is the direct-message counterpart of the channel view's content match: the frames
|
|
cannot be read, but copies of one message carry the same MAC over its ciphertext and the
|
|
next message's carry a different one.
|
|
"""
|
|
repo, run = _repo(tmp_path)
|
|
now = utcnow()
|
|
# First send, retried twice and heard off two repeaters.
|
|
for offset, path in ((1, "3d63"), (1, "27d4"), (6, "3d63"), (6, "27d4")):
|
|
_record_frame(repo, run, when=now + timedelta(seconds=offset), path=path,
|
|
raw=_direct_raw(dest="d4", src="a1", mac="beef"))
|
|
# Second send, twelve seconds later, same pair and same paths.
|
|
for offset, path in ((13, "3d63"), (13, "27d4")):
|
|
_record_frame(repo, run, when=now + timedelta(seconds=offset), path=path,
|
|
raw=_direct_raw(dest="d4", src="a1", mac="f00d"))
|
|
|
|
keys = dict(self_key="a1" + "0" * 62, peer_key="d4e5f6a7")
|
|
first = ChatMessage(text="one", outbound=True, peer="d4e5f6a7", created_at=now)
|
|
second = ChatMessage(
|
|
text="two", outbound=True, peer="d4e5f6a7",
|
|
created_at=now + timedelta(seconds=12),
|
|
)
|
|
got, exact = direct_arrivals(repo, first, **keys)
|
|
assert exact is True
|
|
assert len(got) == 4, "the first send's four frames, and not the second's"
|
|
assert len(direct_arrivals(repo, second, **keys)[0]) == 2
|
|
repo.close()
|
|
|
|
|
|
def test_the_window_clamps_to_the_messages_going_the_same_way(tmp_path: Path) -> None:
|
|
"""Each direction is bounded by its own neighbours, and bounded differently.
|
|
|
|
Without a clamp a fast exchange put every message's frames in every other's view — nine
|
|
messages of one real conversation fell inside a single flat window. With the wrong
|
|
clamp, a *received* message got an empty one: its stamp is the sender's clock, so
|
|
ordering it against our own sends compares two clocks and can bound it by an edge that,
|
|
in its own clock, hasn't happened yet (JP, 2026-09-02).
|
|
"""
|
|
from meshterm.services.message_paths import _DIRECT_WINDOW, direct_window
|
|
|
|
repo, run = _repo(tmp_path)
|
|
now = utcnow()
|
|
sent = [now, now + timedelta(seconds=20)]
|
|
received = [now - timedelta(seconds=30), now + timedelta(seconds=50)]
|
|
for at in sent:
|
|
repo.record_chat_message(
|
|
ChatMessage(text="ours", outbound=True, peer="d4e5f6a7", created_at=at)
|
|
)
|
|
for at in received:
|
|
repo.record_chat_message(
|
|
ChatMessage(text="theirs", outbound=False, peer="d4e5f6a7", created_at=at)
|
|
)
|
|
|
|
# Our own send: our clock, so nothing of it predates it — the window opens at the
|
|
# message and runs forward to the next send, where retries actually live.
|
|
ours = ChatMessage(text="ours", outbound=True, peer="d4e5f6a7", created_at=now)
|
|
start, end = direct_window(repo, ours)
|
|
assert (now - start) < timedelta(seconds=5), "no room behind a send"
|
|
assert end == sent[1], "forward to the next send, not to the next received message"
|
|
|
|
# A received message keeps a centred window, reaching halfway to the received messages
|
|
# either side of it — and is untouched by our own sends in between.
|
|
theirs = ChatMessage(
|
|
text="theirs", outbound=False, peer="d4e5f6a7", created_at=now + timedelta(seconds=50)
|
|
)
|
|
start, end = direct_window(repo, theirs)
|
|
assert start == theirs.created_at - timedelta(seconds=40), "halfway back to the last one"
|
|
assert end == theirs.created_at + _DIRECT_WINDOW, "nothing follows, so that side stands"
|
|
repo.close()
|
|
|
|
|
|
|
|
def test_collapse_folds_a_repeated_path_into_one_counted_row(tmp_path: Path) -> None:
|
|
"""One row per path, carrying its copy count, first sighting and best SNR."""
|
|
from meshterm.services.message_paths import Arrival
|
|
|
|
base = utcnow()
|
|
arrivals = [
|
|
Arrival(when=base, hops=("3d63",), snr=10.0),
|
|
Arrival(when=base + timedelta(seconds=1), hops=("27d4",), snr=5.0),
|
|
Arrival(when=base + timedelta(seconds=5), hops=("3d63",), snr=13.5),
|
|
]
|
|
folded = collapse(arrivals)
|
|
assert [a.hops for a in folded] == [("3d63",), ("27d4",)], "first-heard order"
|
|
assert folded[0].copies == 2 and folded[1].copies == 1
|
|
assert folded[0].when == base, "the first sighting is when the path first worked"
|
|
assert folded[0].snr == 13.5, "the best reading is what the path can do"
|
|
|
|
|
|
|
|
def test_a_routed_frames_path_is_where_it_was_going_not_where_it_has_been(
|
|
tmp_path: Path,
|
|
) -> None:
|
|
"""The route type decides what ``path`` means, and an empty routed one means nothing.
|
|
|
|
A flooded packet accumulates its path — every relay appends itself — so the hops are the
|
|
route it travelled to us. A direct-routed packet carries a route its sender wrote and
|
|
its relays consume, so an empty path means the route was used up, not that the packet
|
|
crossed no relays. Read the second as the first and a message from five hops away is
|
|
drawn as having arrived out of thin air: the "impossible direct path" (JP, 2026-09-02).
|
|
"""
|
|
repo, run = _repo(tmp_path)
|
|
now = utcnow()
|
|
_record_frame( # flooded, one relay: it really did come to us that way
|
|
repo, run, when=now + timedelta(seconds=1), path="3d63",
|
|
raw=_direct_raw(dest="a1", src="d4", mac="beef", route="FLOOD"),
|
|
)
|
|
_record_frame( # direct-routed, route consumed: says nothing about how it got here
|
|
repo, run, when=now + timedelta(seconds=2), path="",
|
|
raw=_direct_raw(dest="a1", src="d4", mac="beef", route="DIRECT"),
|
|
)
|
|
message = ChatMessage(text="hi", outbound=False, peer="d4e5f6a7", created_at=now)
|
|
arrivals, _exact = direct_arrivals(
|
|
repo, message, self_key="a1" + "0" * 62, peer_key="d4e5f6a7"
|
|
)
|
|
flooded, routed = arrivals
|
|
assert flooded.routed is False and flooded.route_known is True
|
|
assert routed.routed is True and routed.route_known is False, (
|
|
"an empty routed path is not a zero-hop arrival"
|
|
)
|
|
repo.close()
|
|
|
|
|
|
def test_history_without_a_route_type_is_unknown_rather_than_assumed(tmp_path: Path) -> None:
|
|
"""Frames recorded before the route type was kept read as before, not as guesses."""
|
|
repo, run = _repo(tmp_path)
|
|
now = utcnow()
|
|
_record_frame(
|
|
repo, run, when=now + timedelta(seconds=1), path="",
|
|
raw=_direct_raw(dest="a1", src="d4", mac="beef"),
|
|
)
|
|
message = ChatMessage(text="hi", outbound=False, peer="d4e5f6a7", created_at=now)
|
|
arrivals, _exact = direct_arrivals(
|
|
repo, message, self_key="a1" + "0" * 62, peer_key="d4e5f6a7"
|
|
)
|
|
assert arrivals[0].routed is None
|
|
assert arrivals[0].route_known is True, "unknown falls back to the old reading"
|
|
repo.close()
|
|
|
|
|
|
def test_collapse_keeps_a_routed_path_apart_from_the_same_hops_flooded(tmp_path: Path) -> None:
|
|
"""Identical hashes mean two different things under the two route types."""
|
|
from meshterm.services.message_paths import Arrival
|
|
|
|
base = utcnow()
|
|
folded = collapse([
|
|
Arrival(when=base, hops=("3d63",), snr=1.0, routed=False),
|
|
Arrival(when=base + timedelta(seconds=1), hops=("3d63",), snr=2.0, routed=True),
|
|
])
|
|
assert len(folded) == 2, "a route travelled is not a route intended"
|
|
assert [a.routed for a in folded] == [False, True]
|