mirror of
https://github.com/jkingsman/Remote-Terminal-for-MeshCore.git
synced 2026-08-07 09:13:04 +02:00
Pass 1 on PATH integration
This commit is contained in:
@@ -90,9 +90,9 @@ export interface Contact {
|
||||
name: string | null;
|
||||
type: number;
|
||||
flags: number;
|
||||
last_path: string | null;
|
||||
last_path_len: number;
|
||||
out_path_hash_mode: number;
|
||||
direct_path: string | null;
|
||||
direct_path_len: number;
|
||||
direct_path_hash_mode: number;
|
||||
route_override_path?: string | null;
|
||||
route_override_len?: number | null;
|
||||
route_override_hash_mode?: number | null;
|
||||
|
||||
@@ -588,12 +588,13 @@ class TestRoutingOverride:
|
||||
assert contact.last_path_len == 1
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_blank_route_clears_override_and_resets_learned_path(self, test_db, client):
|
||||
async def test_blank_route_clears_override_and_preserves_learned_path(self, test_db, client):
|
||||
await _insert_contact(
|
||||
KEY_A,
|
||||
last_path="11",
|
||||
last_path_len=1,
|
||||
out_path_hash_mode=0,
|
||||
direct_path="11",
|
||||
direct_path_len=1,
|
||||
direct_path_hash_mode=0,
|
||||
direct_path_updated_at=1700000000,
|
||||
route_override_path="ae92f13e",
|
||||
route_override_len=2,
|
||||
route_override_hash_mode=1,
|
||||
@@ -613,9 +614,10 @@ class TestRoutingOverride:
|
||||
contact = await ContactRepository.get_by_key(KEY_A)
|
||||
assert contact is not None
|
||||
assert contact.route_override_len is None
|
||||
assert contact.last_path == ""
|
||||
assert contact.last_path_len == -1
|
||||
assert contact.out_path_hash_mode == -1
|
||||
assert contact.direct_path == "11"
|
||||
assert contact.direct_path_len == 1
|
||||
assert contact.direct_path_hash_mode == 0
|
||||
assert contact.direct_path_updated_at == 1700000000
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_rejects_invalid_explicit_route(self, test_db, client):
|
||||
|
||||
@@ -16,12 +16,14 @@ from app.decoder import (
|
||||
_clamp_scalar,
|
||||
decrypt_direct_message,
|
||||
decrypt_group_text,
|
||||
decrypt_path_payload,
|
||||
derive_public_key,
|
||||
derive_shared_secret,
|
||||
extract_payload,
|
||||
parse_packet,
|
||||
try_decrypt_dm,
|
||||
try_decrypt_packet_with_channel_key,
|
||||
try_decrypt_path,
|
||||
)
|
||||
|
||||
|
||||
@@ -298,6 +300,181 @@ class TestGroupTextDecryption:
|
||||
assert result is None
|
||||
|
||||
|
||||
class TestPathDecryption:
|
||||
"""Test PATH payload decryption against the firmware wire format."""
|
||||
|
||||
WORKED_PATH_PACKET = bytes.fromhex("22007EDE577469F4134F9B00EDD57EB4353A1B5999B7")
|
||||
WORKED_PATH_SENDER_PRIV = bytes.fromhex(
|
||||
"489E11DCC0A5E037E65C90D2327AA11A42EAFE0C9F68DEBE82B0F71C88C0874B"
|
||||
"CC291D9B2B98A54F5C1426B7AB8156B0D684EAA4EBA755AC614A9FD32B74C308"
|
||||
)
|
||||
WORKED_PATH_DEST_PUB = bytes.fromhex(
|
||||
"7e23132922070404863fe855248ce414b64012c891342c1fc7ee5bd3d51ea405"
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _create_encrypted_path_payload(
|
||||
*,
|
||||
shared_secret: bytes,
|
||||
dest_hash: int,
|
||||
src_hash: int,
|
||||
packed_path_len: int,
|
||||
path_bytes: bytes,
|
||||
extra_type: int,
|
||||
extra: bytes,
|
||||
) -> bytes:
|
||||
plaintext = bytes([packed_path_len]) + path_bytes + bytes([extra_type]) + extra
|
||||
pad_len = (16 - len(plaintext) % 16) % 16
|
||||
if pad_len == 0:
|
||||
pad_len = 16
|
||||
plaintext += bytes(pad_len)
|
||||
|
||||
cipher = AES.new(shared_secret[:16], AES.MODE_ECB)
|
||||
ciphertext = cipher.encrypt(plaintext)
|
||||
mac = hmac.new(shared_secret, ciphertext, hashlib.sha256).digest()[:2]
|
||||
return bytes([dest_hash, src_hash]) + mac + ciphertext
|
||||
|
||||
def test_decrypt_path_payload_matches_firmware_layout(self):
|
||||
"""PATH packets are dest/src hashes plus MAC+ciphertext; decrypted data is path+extra."""
|
||||
shared_secret = bytes(range(32))
|
||||
payload = self._create_encrypted_path_payload(
|
||||
shared_secret=shared_secret,
|
||||
dest_hash=0xAE,
|
||||
src_hash=0x11,
|
||||
packed_path_len=0x42, # mode 1 (2-byte hops), 2 hops
|
||||
path_bytes=bytes.fromhex("aabbccdd"),
|
||||
extra_type=PayloadType.ACK,
|
||||
extra=bytes.fromhex("01020304"),
|
||||
)
|
||||
|
||||
result = decrypt_path_payload(payload, shared_secret)
|
||||
|
||||
assert result is not None
|
||||
assert result.dest_hash == "ae"
|
||||
assert result.src_hash == "11"
|
||||
assert result.returned_path == bytes.fromhex("aabbccdd")
|
||||
assert result.returned_path_len == 2
|
||||
assert result.returned_path_hash_mode == 1
|
||||
assert result.extra_type == PayloadType.ACK
|
||||
assert result.extra[:4] == bytes.fromhex("01020304")
|
||||
|
||||
def test_decrypt_path_payload_rejects_corrupted_mac(self):
|
||||
"""PATH payloads with a bad MAC must be rejected."""
|
||||
shared_secret = bytes(range(32))
|
||||
payload = self._create_encrypted_path_payload(
|
||||
shared_secret=shared_secret,
|
||||
dest_hash=0xAE,
|
||||
src_hash=0x11,
|
||||
packed_path_len=0x00,
|
||||
path_bytes=b"",
|
||||
extra_type=PayloadType.RESPONSE,
|
||||
extra=b"\x99\x88",
|
||||
)
|
||||
corrupted = payload[:2] + bytes([payload[2] ^ 0xFF, payload[3]]) + payload[4:]
|
||||
|
||||
result = decrypt_path_payload(corrupted, shared_secret)
|
||||
|
||||
assert result is None
|
||||
|
||||
def test_decrypt_worked_path_packet_fixture(self):
|
||||
"""Worked PATH sample from the design doc decrypts as a direct route."""
|
||||
packet = parse_packet(self.WORKED_PATH_PACKET)
|
||||
assert packet is not None
|
||||
assert packet.payload_type == PayloadType.PATH
|
||||
|
||||
shared_secret = derive_shared_secret(
|
||||
self.WORKED_PATH_SENDER_PRIV, self.WORKED_PATH_DEST_PUB
|
||||
)
|
||||
result = decrypt_path_payload(packet.payload, shared_secret)
|
||||
|
||||
assert result is not None
|
||||
assert result.dest_hash == "7e"
|
||||
assert result.src_hash == "de"
|
||||
assert result.returned_path == b""
|
||||
assert result.returned_path_len == 0
|
||||
assert result.returned_path_hash_mode == 0
|
||||
assert result.extra_type == 0x0F
|
||||
|
||||
|
||||
class TestTryDecryptPath:
|
||||
"""Test the full PATH decryption wrapper."""
|
||||
|
||||
OUR_PRIV = bytes.fromhex(
|
||||
"58BA1940E97099CBB4357C62CE9C7F4B245C94C90D722E67201B989F9FEACF7B"
|
||||
"77ACADDB84438514022BDB0FC3140C2501859BE1772AC7B8C7E41DC0F40490A1"
|
||||
)
|
||||
THEIR_PUB = bytes.fromhex("a1b2c3d3ba9f5fa8705b9845fe11cc6f01d1d49caaf4d122ac7121663c5beec7")
|
||||
|
||||
@classmethod
|
||||
def _make_path_packet(
|
||||
cls,
|
||||
*,
|
||||
packed_path_len: int,
|
||||
path_bytes: bytes,
|
||||
extra_type: int,
|
||||
extra: bytes,
|
||||
) -> bytes:
|
||||
shared_secret = derive_shared_secret(cls.OUR_PRIV, cls.THEIR_PUB)
|
||||
plaintext = bytes([packed_path_len]) + path_bytes + bytes([extra_type]) + extra
|
||||
pad_len = (16 - len(plaintext) % 16) % 16
|
||||
if pad_len == 0:
|
||||
pad_len = 16
|
||||
plaintext += bytes(pad_len)
|
||||
|
||||
cipher = AES.new(shared_secret[:16], AES.MODE_ECB)
|
||||
ciphertext = cipher.encrypt(plaintext)
|
||||
mac = hmac.new(shared_secret, ciphertext, hashlib.sha256).digest()[:2]
|
||||
our_public = derive_public_key(cls.OUR_PRIV)
|
||||
return (
|
||||
bytes([(PayloadType.PATH << 2) | RouteType.DIRECT, 0x00])
|
||||
+ bytes([our_public[0], cls.THEIR_PUB[0]])
|
||||
+ mac
|
||||
+ ciphertext
|
||||
)
|
||||
|
||||
def test_try_decrypt_path_decrypts_full_packet(self):
|
||||
"""try_decrypt_path validates hashes, derives ECDH, and returns the route."""
|
||||
raw_packet = self._make_path_packet(
|
||||
packed_path_len=0x42,
|
||||
path_bytes=bytes.fromhex("aabbccdd"),
|
||||
extra_type=PayloadType.ACK,
|
||||
extra=bytes.fromhex("01020304"),
|
||||
)
|
||||
|
||||
result = try_decrypt_path(
|
||||
raw_packet=raw_packet,
|
||||
our_private_key=self.OUR_PRIV,
|
||||
their_public_key=self.THEIR_PUB,
|
||||
our_public_key=derive_public_key(self.OUR_PRIV),
|
||||
)
|
||||
|
||||
assert result is not None
|
||||
assert result.returned_path == bytes.fromhex("aabbccdd")
|
||||
assert result.returned_path_len == 2
|
||||
assert result.returned_path_hash_mode == 1
|
||||
assert result.extra_type == PayloadType.ACK
|
||||
assert result.extra[:4] == bytes.fromhex("01020304")
|
||||
|
||||
def test_try_decrypt_path_rejects_hash_mismatch(self):
|
||||
"""Packets addressed to another destination are rejected before decryption."""
|
||||
raw_packet = self._make_path_packet(
|
||||
packed_path_len=0x00,
|
||||
path_bytes=b"",
|
||||
extra_type=PayloadType.RESPONSE,
|
||||
extra=b"\xaa",
|
||||
)
|
||||
wrong_our_public = bytes.fromhex("ff") + derive_public_key(self.OUR_PRIV)[1:]
|
||||
|
||||
result = try_decrypt_path(
|
||||
raw_packet=raw_packet,
|
||||
our_private_key=self.OUR_PRIV,
|
||||
their_public_key=self.THEIR_PUB,
|
||||
our_public_key=wrong_our_public,
|
||||
)
|
||||
|
||||
assert result is None
|
||||
|
||||
|
||||
class TestTryDecryptPacket:
|
||||
"""Test the full packet decryption pipeline."""
|
||||
|
||||
|
||||
+20
-18
@@ -1247,23 +1247,25 @@ class TestMigration039:
|
||||
|
||||
applied = await run_migrations(conn)
|
||||
|
||||
assert applied == 6
|
||||
assert await get_version(conn) == 44
|
||||
assert applied == 7
|
||||
assert await get_version(conn) == 45
|
||||
|
||||
cursor = await conn.execute(
|
||||
"""
|
||||
SELECT public_key, last_path_len, out_path_hash_mode
|
||||
SELECT public_key, direct_path, direct_path_len, direct_path_hash_mode
|
||||
FROM contacts
|
||||
ORDER BY public_key
|
||||
"""
|
||||
)
|
||||
rows = await cursor.fetchall()
|
||||
assert rows[0]["public_key"] == "aa" * 32
|
||||
assert rows[0]["last_path_len"] == -1
|
||||
assert rows[0]["out_path_hash_mode"] == -1
|
||||
assert rows[0]["direct_path"] == ""
|
||||
assert rows[0]["direct_path_len"] == -1
|
||||
assert rows[0]["direct_path_hash_mode"] == -1
|
||||
assert rows[1]["public_key"] == "bb" * 32
|
||||
assert rows[1]["last_path_len"] == 1
|
||||
assert rows[1]["out_path_hash_mode"] == 0
|
||||
assert rows[1]["direct_path"] == "1122"
|
||||
assert rows[1]["direct_path_len"] == 1
|
||||
assert rows[1]["direct_path_hash_mode"] == 0
|
||||
finally:
|
||||
await conn.close()
|
||||
|
||||
@@ -1317,12 +1319,12 @@ class TestMigration039:
|
||||
|
||||
applied = await run_migrations(conn)
|
||||
|
||||
assert applied == 6
|
||||
assert await get_version(conn) == 44
|
||||
assert applied == 7
|
||||
assert await get_version(conn) == 45
|
||||
|
||||
cursor = await conn.execute(
|
||||
"""
|
||||
SELECT public_key, out_path_hash_mode
|
||||
SELECT public_key, direct_path_hash_mode
|
||||
FROM contacts
|
||||
WHERE public_key IN (?, ?)
|
||||
ORDER BY public_key
|
||||
@@ -1331,9 +1333,9 @@ class TestMigration039:
|
||||
)
|
||||
rows = await cursor.fetchall()
|
||||
assert rows[0]["public_key"] == "cc" * 32
|
||||
assert rows[0]["out_path_hash_mode"] == 1
|
||||
assert rows[0]["direct_path_hash_mode"] == 1
|
||||
assert rows[1]["public_key"] == "dd" * 32
|
||||
assert rows[1]["out_path_hash_mode"] == -1
|
||||
assert rows[1]["direct_path_hash_mode"] == -1
|
||||
finally:
|
||||
await conn.close()
|
||||
|
||||
@@ -1371,8 +1373,8 @@ class TestMigration040:
|
||||
|
||||
applied = await run_migrations(conn)
|
||||
|
||||
assert applied == 5
|
||||
assert await get_version(conn) == 44
|
||||
assert applied == 6
|
||||
assert await get_version(conn) == 45
|
||||
|
||||
await conn.execute(
|
||||
"""
|
||||
@@ -1433,8 +1435,8 @@ class TestMigration041:
|
||||
|
||||
applied = await run_migrations(conn)
|
||||
|
||||
assert applied == 4
|
||||
assert await get_version(conn) == 44
|
||||
assert applied == 5
|
||||
assert await get_version(conn) == 45
|
||||
|
||||
await conn.execute(
|
||||
"""
|
||||
@@ -1486,8 +1488,8 @@ class TestMigration042:
|
||||
|
||||
applied = await run_migrations(conn)
|
||||
|
||||
assert applied == 3
|
||||
assert await get_version(conn) == 44
|
||||
assert applied == 4
|
||||
assert await get_version(conn) == 45
|
||||
|
||||
await conn.execute(
|
||||
"""
|
||||
|
||||
+258
-70
@@ -12,10 +12,20 @@ from pathlib import Path
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from Crypto.Cipher import AES
|
||||
|
||||
from app.decoder import DecryptedDirectMessage, PacketInfo, ParsedAdvertisement, PayloadType
|
||||
from app.decoder import (
|
||||
DecryptedDirectMessage,
|
||||
PacketInfo,
|
||||
ParsedAdvertisement,
|
||||
PayloadType,
|
||||
RouteType,
|
||||
derive_public_key,
|
||||
derive_shared_secret,
|
||||
)
|
||||
from app.repository import (
|
||||
ChannelRepository,
|
||||
ContactAdvertPathRepository,
|
||||
ContactRepository,
|
||||
MessageRepository,
|
||||
RawPacketRepository,
|
||||
@@ -27,6 +37,43 @@ with open(FIXTURES_PATH) as f:
|
||||
FIXTURES = json.load(f)
|
||||
|
||||
|
||||
PATH_TEST_OUR_PRIV = bytes.fromhex(
|
||||
"58BA1940E97099CBB4357C62CE9C7F4B245C94C90D722E67201B989F9FEACF7B"
|
||||
"77ACADDB84438514022BDB0FC3140C2501859BE1772AC7B8C7E41DC0F40490A1"
|
||||
)
|
||||
PATH_TEST_CONTACT_PUB = bytes.fromhex(
|
||||
"a1b2c3d3ba9f5fa8705b9845fe11cc6f01d1d49caaf4d122ac7121663c5beec7"
|
||||
)
|
||||
PATH_TEST_OUR_PUB = derive_public_key(PATH_TEST_OUR_PRIV)
|
||||
|
||||
|
||||
def _build_path_packet(
|
||||
*,
|
||||
packed_path_len: int,
|
||||
path_bytes: bytes,
|
||||
extra_type: int,
|
||||
extra: bytes,
|
||||
route_type: RouteType = RouteType.DIRECT,
|
||||
) -> bytes:
|
||||
shared_secret = derive_shared_secret(PATH_TEST_OUR_PRIV, PATH_TEST_CONTACT_PUB)
|
||||
plaintext = bytes([packed_path_len]) + path_bytes + bytes([extra_type]) + extra
|
||||
pad_len = (16 - len(plaintext) % 16) % 16
|
||||
if pad_len == 0:
|
||||
pad_len = 16
|
||||
plaintext += bytes(pad_len)
|
||||
|
||||
cipher = AES.new(shared_secret[:16], AES.MODE_ECB)
|
||||
ciphertext = cipher.encrypt(plaintext)
|
||||
|
||||
import hmac
|
||||
from hashlib import sha256
|
||||
|
||||
mac = hmac.new(shared_secret, ciphertext, sha256).digest()[:2]
|
||||
header = bytes([(PayloadType.PATH << 2) | route_type, 0x00])
|
||||
payload = bytes([PATH_TEST_OUR_PUB[0], PATH_TEST_CONTACT_PUB[0]]) + mac + ciphertext
|
||||
return header + payload
|
||||
|
||||
|
||||
class TestChannelMessagePipeline:
|
||||
"""Test channel message flow: packet → decrypt → store → broadcast."""
|
||||
|
||||
@@ -169,10 +216,13 @@ class TestAdvertisementPipeline:
|
||||
assert contact.lon is not None
|
||||
assert abs(contact.lat - expected["lat"]) < 0.001
|
||||
assert abs(contact.lon - expected["lon"]) < 0.001
|
||||
# This advertisement has path_len=6 (6 hops through repeaters)
|
||||
assert contact.last_path_len == 6
|
||||
assert contact.last_path is not None
|
||||
assert len(contact.last_path) == 12 # 6 bytes = 12 hex chars
|
||||
assert contact.last_path_len == -1
|
||||
assert contact.last_path in (None, "")
|
||||
|
||||
advert_paths = await ContactAdvertPathRepository.get_recent_for_contact(contact.public_key)
|
||||
assert len(advert_paths) == 1
|
||||
assert advert_paths[0].path_len == 6
|
||||
assert len(advert_paths[0].path) == 12 # 6 bytes = 12 hex chars
|
||||
|
||||
# Verify WebSocket broadcast
|
||||
contact_broadcasts = [b for b in broadcasts if b["type"] == "contact"]
|
||||
@@ -182,7 +232,8 @@ class TestAdvertisementPipeline:
|
||||
assert broadcast["data"]["public_key"] == expected["public_key"]
|
||||
assert broadcast["data"]["name"] == expected["name"]
|
||||
assert broadcast["data"]["type"] == expected["type"]
|
||||
assert broadcast["data"]["last_path_len"] == 6
|
||||
assert broadcast["data"]["direct_path_len"] == -1
|
||||
assert "last_path_len" not in broadcast["data"]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_advertisement_updates_existing_contact(self, test_db, captured_broadcasts):
|
||||
@@ -216,10 +267,11 @@ class TestAdvertisementPipeline:
|
||||
assert contact.type == expected["type"] # Type updated
|
||||
assert contact.lat is not None # GPS added
|
||||
assert contact.lon is not None
|
||||
# This advertisement has path_len=0 (direct neighbor)
|
||||
assert contact.last_path_len == 0
|
||||
# Empty path stored as None or ""
|
||||
assert contact.last_path in (None, "")
|
||||
|
||||
advert_paths = await ContactAdvertPathRepository.get_recent_for_contact(contact.public_key)
|
||||
assert len(advert_paths) == 1
|
||||
assert advert_paths[0].path_len == 0
|
||||
assert advert_paths[0].path == ""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_advertisement_triggers_historical_decrypt_for_new_contact(
|
||||
@@ -278,42 +330,38 @@ class TestAdvertisementPipeline:
|
||||
assert mock_start.await_count == 0
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_advertisement_keeps_shorter_path_within_window(
|
||||
async def test_advertisement_records_recent_unique_paths_without_changing_direct_route(
|
||||
self, test_db, captured_broadcasts
|
||||
):
|
||||
"""When receiving echoed advertisements, keep the shortest path within 60s window."""
|
||||
"""Advertisement paths are informational and do not replace the stored direct route."""
|
||||
from app.packet_processor import _process_advertisement
|
||||
|
||||
# Create a contact with a longer path (path_len=3)
|
||||
test_pubkey = "1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef"
|
||||
await ContactRepository.upsert(
|
||||
{
|
||||
"public_key": test_pubkey,
|
||||
"name": "TestNode",
|
||||
"type": 1,
|
||||
"direct_path": "aabbcc",
|
||||
"direct_path_len": 3,
|
||||
"direct_path_hash_mode": 0,
|
||||
"last_advert": 1000,
|
||||
"last_seen": 1000,
|
||||
"last_path_len": 3,
|
||||
"last_path": "aabbcc", # 3 bytes = 3 hops
|
||||
}
|
||||
)
|
||||
|
||||
# Simulate receiving a shorter path (path_len=1) within 60s
|
||||
# We'll call _process_advertisement directly with mock packet_info
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
from app.decoder import ParsedAdvertisement
|
||||
|
||||
broadcasts, mock_broadcast = captured_broadcasts
|
||||
|
||||
# Mock packet_info with shorter path
|
||||
short_packet_info = MagicMock()
|
||||
short_packet_info.path_length = 1
|
||||
short_packet_info.path = bytes.fromhex("aa")
|
||||
short_packet_info.path_hash_size = 1
|
||||
short_packet_info.payload = b"" # Will be parsed by parse_advertisement
|
||||
short_packet_info.payload = b""
|
||||
|
||||
# Mock parse_advertisement to return our test contact
|
||||
with patch("app.packet_processor.broadcast_event", mock_broadcast):
|
||||
with patch("app.packet_processor.parse_advertisement") as mock_parse:
|
||||
mock_parse.return_value = ParsedAdvertisement(
|
||||
@@ -324,18 +372,13 @@ class TestAdvertisementPipeline:
|
||||
lon=None,
|
||||
device_role=1,
|
||||
)
|
||||
# Process at timestamp 1050 (within 60s of last_seen=1000)
|
||||
await _process_advertisement(b"", timestamp=1050, packet_info=short_packet_info)
|
||||
|
||||
# Verify the shorter path was stored
|
||||
contact = await ContactRepository.get_by_key(test_pubkey)
|
||||
assert contact.last_path_len == 1 # Updated to shorter path
|
||||
|
||||
# Now simulate receiving a longer path (path_len=5) - should keep the shorter one
|
||||
long_packet_info = MagicMock()
|
||||
long_packet_info.path_length = 5
|
||||
long_packet_info.path = bytes.fromhex("aabbccddee")
|
||||
long_packet_info.path_hash_size = 1
|
||||
long_packet_info.payload = b""
|
||||
|
||||
with patch("app.packet_processor.broadcast_event", mock_broadcast):
|
||||
with patch("app.packet_processor.parse_advertisement") as mock_parse:
|
||||
@@ -347,35 +390,33 @@ class TestAdvertisementPipeline:
|
||||
lon=None,
|
||||
device_role=1,
|
||||
)
|
||||
# Process at timestamp 1055 (within 60s of last update)
|
||||
await _process_advertisement(b"", timestamp=1055, packet_info=long_packet_info)
|
||||
|
||||
# Verify the shorter path was kept
|
||||
contact = await ContactRepository.get_by_key(test_pubkey)
|
||||
assert contact.last_path_len == 1 # Still the shorter path
|
||||
assert contact is not None
|
||||
assert contact.direct_path == "aabbcc"
|
||||
assert contact.direct_path_len == 3
|
||||
assert contact.direct_path_hash_mode == 0
|
||||
assert contact.direct_path_updated_at is None
|
||||
|
||||
advert_paths = await ContactAdvertPathRepository.get_recent_for_contact(test_pubkey)
|
||||
assert [(path.path, path.path_len) for path in advert_paths] == [
|
||||
("aabbccddee", 5),
|
||||
("aa", 1),
|
||||
]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_advertisement_path_freshness_uses_receive_time_not_sender_clock(
|
||||
self, test_db, captured_broadcasts
|
||||
):
|
||||
"""Sender clock skew should not keep an old advert path artificially fresh."""
|
||||
"""Advert history timestamps use receive time instead of sender clock."""
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
from app.decoder import ParsedAdvertisement
|
||||
from app.packet_processor import _process_advertisement
|
||||
|
||||
test_pubkey = "abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890"
|
||||
await ContactRepository.upsert(
|
||||
{
|
||||
"public_key": test_pubkey,
|
||||
"name": "TestNode",
|
||||
"type": 1,
|
||||
"last_advert": 1000,
|
||||
"last_seen": 1055, # Simulates later non-advert activity
|
||||
"last_path_len": 1,
|
||||
"last_path": "aa",
|
||||
}
|
||||
)
|
||||
await ContactRepository.upsert({"public_key": test_pubkey, "name": "TestNode", "type": 1})
|
||||
|
||||
broadcasts, mock_broadcast = captured_broadcasts
|
||||
|
||||
@@ -419,19 +460,19 @@ class TestAdvertisementPipeline:
|
||||
|
||||
contact = await ContactRepository.get_by_key(test_pubkey)
|
||||
assert contact is not None
|
||||
assert contact.last_path_len == 3
|
||||
assert contact.last_path == "aabbcc"
|
||||
assert contact.last_advert == 1200
|
||||
|
||||
advert_paths = await ContactAdvertPathRepository.get_recent_for_contact(test_pubkey)
|
||||
assert [(path.path, path.last_seen) for path in advert_paths] == [
|
||||
("aabbcc", 1200),
|
||||
("aa", 1070),
|
||||
]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_advertisement_default_path_len_treated_as_infinity(
|
||||
async def test_advertisement_records_path_history_when_no_direct_route_exists(
|
||||
self, test_db, captured_broadcasts
|
||||
):
|
||||
"""Contact with last_path_len=-1 (unset) is treated as infinite length.
|
||||
|
||||
Any new advertisement should replace the default -1 path since
|
||||
the code converts -1 to float('inf') for comparison.
|
||||
"""
|
||||
"""Advertisement path history is still recorded when no direct route exists."""
|
||||
from app.packet_processor import _process_advertisement
|
||||
|
||||
test_pubkey = "1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef"
|
||||
@@ -441,8 +482,6 @@ class TestAdvertisementPipeline:
|
||||
"name": "TestNode",
|
||||
"type": 1,
|
||||
"last_seen": 1000,
|
||||
"last_path_len": -1, # Default unset value
|
||||
"last_path": None,
|
||||
}
|
||||
)
|
||||
|
||||
@@ -465,23 +504,21 @@ class TestAdvertisementPipeline:
|
||||
lon=None,
|
||||
device_role=1,
|
||||
)
|
||||
# Process within 60s window (last_seen=1000, now=1050)
|
||||
await _process_advertisement(b"", timestamp=1050, packet_info=packet_info)
|
||||
|
||||
# Since -1 is treated as infinity, the new path (len=3) should replace it
|
||||
contact = await ContactRepository.get_by_key(test_pubkey)
|
||||
assert contact.last_path_len == 3
|
||||
assert contact.last_path == "aabbcc"
|
||||
assert contact is not None
|
||||
assert contact.direct_path_len == -1
|
||||
assert contact.direct_path in (None, "")
|
||||
assert contact.direct_path_updated_at is None
|
||||
advert_paths = await ContactAdvertPathRepository.get_recent_for_contact(test_pubkey)
|
||||
assert len(advert_paths) == 1
|
||||
assert advert_paths[0].path == "aabbcc"
|
||||
assert advert_paths[0].path_len == 3
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_advertisement_replaces_stale_path_outside_window(
|
||||
self, test_db, captured_broadcasts
|
||||
):
|
||||
"""When existing path is stale (>60s), a new longer path should replace it.
|
||||
|
||||
In a mesh network, a stale short path may no longer be valid (node moved, repeater
|
||||
went offline). Accepting the new longer path ensures we have a working route.
|
||||
"""
|
||||
async def test_advertisement_adds_new_unique_history_path(self, test_db, captured_broadcasts):
|
||||
"""A new advertisement path is added to history even when an older path already exists."""
|
||||
from app.packet_processor import _process_advertisement
|
||||
|
||||
test_pubkey = "1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef"
|
||||
@@ -491,10 +528,9 @@ class TestAdvertisementPipeline:
|
||||
"name": "TestNode",
|
||||
"type": 1,
|
||||
"last_seen": 1000,
|
||||
"last_path_len": 1, # Short path
|
||||
"last_path": "aa",
|
||||
}
|
||||
)
|
||||
await ContactAdvertPathRepository.record_observation(test_pubkey, "aa", 1000, hop_count=1)
|
||||
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
@@ -521,10 +557,158 @@ class TestAdvertisementPipeline:
|
||||
)
|
||||
await _process_advertisement(b"", timestamp=1061, packet_info=long_packet_info)
|
||||
|
||||
# Verify the longer path replaced the stale shorter one
|
||||
contact = await ContactRepository.get_by_key(test_pubkey)
|
||||
assert contact.last_path_len == 4
|
||||
assert contact.last_path == "aabbccdd"
|
||||
assert contact is not None
|
||||
assert contact.last_path_len == -1
|
||||
advert_paths = await ContactAdvertPathRepository.get_recent_for_contact(test_pubkey)
|
||||
assert [(path.path, path.path_len) for path in advert_paths] == [
|
||||
("aabbccdd", 4),
|
||||
("aa", 1),
|
||||
]
|
||||
|
||||
|
||||
class TestPathPacketPipeline:
|
||||
"""Test PATH packet learning and bundled ACK handling."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_process_raw_path_packet_updates_direct_route(self, test_db, captured_broadcasts):
|
||||
"""A decryptable PATH packet updates the contact's learned direct route."""
|
||||
from app.packet_processor import process_raw_packet
|
||||
|
||||
timestamp = 1700000200
|
||||
raw_packet = _build_path_packet(
|
||||
packed_path_len=0x42,
|
||||
path_bytes=bytes.fromhex("aabbccdd"),
|
||||
extra_type=PayloadType.RESPONSE,
|
||||
extra=b"\x11\x22",
|
||||
)
|
||||
|
||||
await ContactRepository.upsert(
|
||||
{
|
||||
"public_key": PATH_TEST_CONTACT_PUB.hex(),
|
||||
"name": "PathPeer",
|
||||
"type": 1,
|
||||
}
|
||||
)
|
||||
|
||||
broadcasts, mock_broadcast = captured_broadcasts
|
||||
|
||||
with (
|
||||
patch("app.packet_processor.broadcast_event", mock_broadcast),
|
||||
patch("app.packet_processor.has_private_key", return_value=True),
|
||||
patch("app.packet_processor.get_private_key", return_value=PATH_TEST_OUR_PRIV),
|
||||
patch("app.packet_processor.get_public_key", return_value=PATH_TEST_OUR_PUB),
|
||||
):
|
||||
result = await process_raw_packet(raw_packet, timestamp=timestamp)
|
||||
|
||||
assert result["payload_type"] == "PATH"
|
||||
contact = await ContactRepository.get_by_key(PATH_TEST_CONTACT_PUB.hex())
|
||||
assert contact is not None
|
||||
assert contact.direct_path == "aabbccdd"
|
||||
assert contact.direct_path_len == 2
|
||||
assert contact.direct_path_hash_mode == 1
|
||||
assert contact.direct_path_updated_at == timestamp
|
||||
|
||||
contact_broadcasts = [b for b in broadcasts if b["type"] == "contact"]
|
||||
assert len(contact_broadcasts) == 1
|
||||
assert contact_broadcasts[0]["data"]["effective_route_source"] == "direct"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_bundled_path_ack_marks_message_acked(self, test_db, captured_broadcasts):
|
||||
"""Bundled ACKs inside PATH packets satisfy the pending DM ACK tracker."""
|
||||
from app.packet_processor import process_raw_packet
|
||||
from app.services import dm_ack_tracker
|
||||
|
||||
raw_packet = _build_path_packet(
|
||||
packed_path_len=0x00,
|
||||
path_bytes=b"",
|
||||
extra_type=PayloadType.ACK,
|
||||
extra=bytes.fromhex("01020304feedface"),
|
||||
)
|
||||
|
||||
await ContactRepository.upsert(
|
||||
{
|
||||
"public_key": PATH_TEST_CONTACT_PUB.hex(),
|
||||
"name": "AckPeer",
|
||||
"type": 1,
|
||||
}
|
||||
)
|
||||
message_id = await MessageRepository.create(
|
||||
msg_type="PRIV",
|
||||
text="waiting for ack",
|
||||
conversation_key=PATH_TEST_CONTACT_PUB.hex(),
|
||||
sender_timestamp=1700000000,
|
||||
received_at=1700000000,
|
||||
outgoing=True,
|
||||
)
|
||||
|
||||
prev_pending = dm_ack_tracker._pending_acks.copy()
|
||||
prev_buffered = dm_ack_tracker._buffered_acks.copy()
|
||||
dm_ack_tracker._pending_acks.clear()
|
||||
dm_ack_tracker._buffered_acks.clear()
|
||||
dm_ack_tracker.track_pending_ack("01020304", message_id, 30000)
|
||||
dm_ack_tracker.track_pending_ack("05060708", message_id, 30000)
|
||||
|
||||
broadcasts, mock_broadcast = captured_broadcasts
|
||||
try:
|
||||
with (
|
||||
patch("app.packet_processor.broadcast_event", mock_broadcast),
|
||||
patch("app.packet_processor.has_private_key", return_value=True),
|
||||
patch("app.packet_processor.get_private_key", return_value=PATH_TEST_OUR_PRIV),
|
||||
patch("app.packet_processor.get_public_key", return_value=PATH_TEST_OUR_PUB),
|
||||
):
|
||||
await process_raw_packet(raw_packet, timestamp=1700000300)
|
||||
finally:
|
||||
pending_after = dm_ack_tracker._pending_acks.copy()
|
||||
dm_ack_tracker._pending_acks.clear()
|
||||
dm_ack_tracker._pending_acks.update(prev_pending)
|
||||
dm_ack_tracker._buffered_acks.clear()
|
||||
dm_ack_tracker._buffered_acks.update(prev_buffered)
|
||||
|
||||
messages = await MessageRepository.get_all(
|
||||
msg_type="PRIV",
|
||||
conversation_key=PATH_TEST_CONTACT_PUB.hex(),
|
||||
limit=10,
|
||||
)
|
||||
assert len(messages) == 1
|
||||
assert messages[0].acked == 1
|
||||
assert "01020304" not in pending_after
|
||||
assert "05060708" not in pending_after
|
||||
|
||||
ack_broadcasts = [b for b in broadcasts if b["type"] == "message_acked"]
|
||||
assert len(ack_broadcasts) == 1
|
||||
assert ack_broadcasts[0]["data"] == {"message_id": message_id, "ack_count": 1}
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_prefix_only_contacts_are_skipped_for_path_decryption(self, test_db):
|
||||
"""Prefix-only contacts are not treated as valid ECDH peers for PATH packets."""
|
||||
from app.packet_processor import _process_path_packet
|
||||
|
||||
raw_packet = _build_path_packet(
|
||||
packed_path_len=0x00,
|
||||
path_bytes=b"",
|
||||
extra_type=PayloadType.RESPONSE,
|
||||
extra=b"\x01",
|
||||
)
|
||||
|
||||
await ContactRepository.upsert(
|
||||
{
|
||||
"public_key": PATH_TEST_CONTACT_PUB.hex()[:12],
|
||||
"name": "PrefixOnly",
|
||||
"type": 1,
|
||||
}
|
||||
)
|
||||
|
||||
with (
|
||||
patch("app.packet_processor.has_private_key", return_value=True),
|
||||
patch("app.packet_processor.get_private_key", return_value=PATH_TEST_OUR_PRIV),
|
||||
patch("app.packet_processor.get_public_key", return_value=PATH_TEST_OUR_PUB),
|
||||
patch(
|
||||
"app.packet_processor.try_decrypt_path",
|
||||
side_effect=AssertionError("prefix-only contacts should be skipped"),
|
||||
),
|
||||
):
|
||||
await _process_path_packet(raw_packet, 1700000400, None)
|
||||
|
||||
|
||||
class TestAckPipeline:
|
||||
@@ -1694,8 +1878,12 @@ class TestProcessRawPacketIntegration:
|
||||
|
||||
contact = await ContactRepository.get_by_key(test_pubkey)
|
||||
assert contact is not None
|
||||
assert contact.last_path_len == 1 # Shorter path won
|
||||
assert contact.last_path == "dd"
|
||||
assert contact.last_path_len == -1
|
||||
advert_paths = await ContactAdvertPathRepository.get_recent_for_contact(test_pubkey)
|
||||
assert [(path.path, path.path_len) for path in advert_paths] == [
|
||||
("dd", 1),
|
||||
("aabbcc", 3),
|
||||
]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_dispatches_text_message(self, test_db, captured_broadcasts):
|
||||
|
||||
+28
-24
@@ -200,16 +200,16 @@ class TestParseExplicitHopRoute:
|
||||
|
||||
|
||||
class TestContactToRadioDictHashMode:
|
||||
"""Test that Contact.to_radio_dict() preserves the stored out_path_hash_mode."""
|
||||
"""Test that Contact.to_radio_dict() preserves the stored direct-route hash mode."""
|
||||
|
||||
def test_preserves_1byte_mode(self):
|
||||
from app.models import Contact
|
||||
|
||||
c = Contact(
|
||||
public_key="aa" * 32,
|
||||
last_path="1a2b3c",
|
||||
last_path_len=3,
|
||||
out_path_hash_mode=0,
|
||||
direct_path="1a2b3c",
|
||||
direct_path_len=3,
|
||||
direct_path_hash_mode=0,
|
||||
)
|
||||
d = c.to_radio_dict()
|
||||
assert d["out_path_hash_mode"] == 0
|
||||
@@ -219,9 +219,9 @@ class TestContactToRadioDictHashMode:
|
||||
|
||||
c = Contact(
|
||||
public_key="bb" * 32,
|
||||
last_path="1a2b3c4d",
|
||||
last_path_len=2,
|
||||
out_path_hash_mode=1,
|
||||
direct_path="1a2b3c4d",
|
||||
direct_path_len=2,
|
||||
direct_path_hash_mode=1,
|
||||
)
|
||||
d = c.to_radio_dict()
|
||||
assert d["out_path_hash_mode"] == 1
|
||||
@@ -231,9 +231,9 @@ class TestContactToRadioDictHashMode:
|
||||
|
||||
c = Contact(
|
||||
public_key="cc" * 32,
|
||||
last_path="1a2b3c4d5e6f",
|
||||
last_path_len=2,
|
||||
out_path_hash_mode=2,
|
||||
direct_path="1a2b3c4d5e6f",
|
||||
direct_path_len=2,
|
||||
direct_path_hash_mode=2,
|
||||
)
|
||||
d = c.to_radio_dict()
|
||||
assert d["out_path_hash_mode"] == 2
|
||||
@@ -243,9 +243,9 @@ class TestContactToRadioDictHashMode:
|
||||
|
||||
c = Contact(
|
||||
public_key="dd" * 32,
|
||||
last_path=None,
|
||||
last_path_len=-1,
|
||||
out_path_hash_mode=-1,
|
||||
direct_path=None,
|
||||
direct_path_len=-1,
|
||||
direct_path_hash_mode=-1,
|
||||
)
|
||||
d = c.to_radio_dict()
|
||||
assert d["out_path_hash_mode"] == -1
|
||||
@@ -255,9 +255,9 @@ class TestContactToRadioDictHashMode:
|
||||
|
||||
c = Contact(
|
||||
public_key="ee" * 32,
|
||||
last_path="aa00bb00",
|
||||
last_path_len=2,
|
||||
out_path_hash_mode=1,
|
||||
direct_path="aa00bb00",
|
||||
direct_path_len=2,
|
||||
direct_path_hash_mode=1,
|
||||
)
|
||||
d = c.to_radio_dict()
|
||||
assert d["out_path_hash_mode"] == 1
|
||||
@@ -267,9 +267,9 @@ class TestContactToRadioDictHashMode:
|
||||
|
||||
c = Contact(
|
||||
public_key="ff" * 32,
|
||||
last_path="3f3f69de1c7b7e7662",
|
||||
last_path_len=-125,
|
||||
out_path_hash_mode=2,
|
||||
direct_path="3f3f69de1c7b7e7662",
|
||||
direct_path_len=-125,
|
||||
direct_path_hash_mode=2,
|
||||
)
|
||||
d = c.to_radio_dict()
|
||||
assert d["out_path"] == "3f3f69de1c7b7e7662"
|
||||
@@ -281,9 +281,9 @@ class TestContactToRadioDictHashMode:
|
||||
|
||||
c = Contact(
|
||||
public_key="11" * 32,
|
||||
last_path="aabb",
|
||||
last_path_len=1,
|
||||
out_path_hash_mode=0,
|
||||
direct_path="aabb",
|
||||
direct_path_len=1,
|
||||
direct_path_hash_mode=0,
|
||||
route_override_path="cc00dd00",
|
||||
route_override_len=2,
|
||||
route_override_hash_mode=1,
|
||||
@@ -309,7 +309,9 @@ class TestContactFromRadioDictHashMode:
|
||||
"out_path_hash_mode": 1,
|
||||
},
|
||||
)
|
||||
assert d["out_path_hash_mode"] == 1
|
||||
assert d["direct_path"] == "aa00bb00"
|
||||
assert d["direct_path_len"] == 2
|
||||
assert d["direct_path_hash_mode"] == 1
|
||||
|
||||
def test_flood_falls_back_to_minus_one(self):
|
||||
from app.models import Contact
|
||||
@@ -322,4 +324,6 @@ class TestContactFromRadioDictHashMode:
|
||||
"out_path_len": -1,
|
||||
},
|
||||
)
|
||||
assert d["out_path_hash_mode"] == -1
|
||||
assert d["direct_path"] == ""
|
||||
assert d["direct_path_len"] == -1
|
||||
assert d["direct_path_hash_mode"] == -1
|
||||
|
||||
+12
-12
@@ -71,9 +71,9 @@ async def _insert_contact(
|
||||
contact_type=0,
|
||||
last_contacted=None,
|
||||
last_advert=None,
|
||||
last_path=None,
|
||||
last_path_len=-1,
|
||||
out_path_hash_mode=0,
|
||||
direct_path=None,
|
||||
direct_path_len=-1,
|
||||
direct_path_hash_mode=-1,
|
||||
):
|
||||
"""Insert a contact into the test database."""
|
||||
await ContactRepository.upsert(
|
||||
@@ -82,9 +82,9 @@ async def _insert_contact(
|
||||
"name": name,
|
||||
"type": contact_type,
|
||||
"flags": 0,
|
||||
"last_path": last_path,
|
||||
"last_path_len": last_path_len,
|
||||
"out_path_hash_mode": out_path_hash_mode,
|
||||
"direct_path": direct_path,
|
||||
"direct_path_len": direct_path_len,
|
||||
"direct_path_hash_mode": direct_path_hash_mode,
|
||||
"last_advert": last_advert,
|
||||
"lat": None,
|
||||
"lon": None,
|
||||
@@ -597,9 +597,9 @@ class TestSyncAndOffloadAll:
|
||||
KEY_A,
|
||||
"Alice",
|
||||
last_contacted=2000,
|
||||
last_path="aa00bb00",
|
||||
last_path_len=2,
|
||||
out_path_hash_mode=1,
|
||||
direct_path="aa00bb00",
|
||||
direct_path_len=2,
|
||||
direct_path_hash_mode=1,
|
||||
)
|
||||
await AppSettingsRepository.update(favorites=[Favorite(type="contact", id=KEY_A)])
|
||||
|
||||
@@ -626,9 +626,9 @@ class TestSyncAndOffloadAll:
|
||||
KEY_A,
|
||||
"Alice",
|
||||
last_contacted=2000,
|
||||
last_path="3f3f69de1c7b7e7662",
|
||||
last_path_len=-125,
|
||||
out_path_hash_mode=2,
|
||||
direct_path="3f3f69de1c7b7e7662",
|
||||
direct_path_len=-125,
|
||||
direct_path_hash_mode=2,
|
||||
)
|
||||
await AppSettingsRepository.update(favorites=[Favorite(type="contact", id=KEY_A)])
|
||||
|
||||
|
||||
@@ -83,8 +83,9 @@ async def _insert_contact(public_key, name="Alice", **overrides):
|
||||
"name": name,
|
||||
"type": 0,
|
||||
"flags": 0,
|
||||
"last_path": None,
|
||||
"last_path_len": -1,
|
||||
"direct_path": None,
|
||||
"direct_path_len": -1,
|
||||
"direct_path_hash_mode": -1,
|
||||
"last_advert": None,
|
||||
"lat": None,
|
||||
"lon": None,
|
||||
@@ -152,16 +153,16 @@ class TestOutgoingDMBroadcast:
|
||||
assert "ambiguous" in exc_info.value.detail.lower()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_send_dm_preserves_stored_out_path_hash_mode(self, test_db):
|
||||
async def test_send_dm_preserves_stored_direct_path_hash_mode(self, test_db):
|
||||
"""Direct-message send pushes the persisted path hash mode back to the radio."""
|
||||
mc = _make_mc()
|
||||
pub_key = "cd" * 32
|
||||
await _insert_contact(
|
||||
pub_key,
|
||||
"Alice",
|
||||
last_path="aa00bb00",
|
||||
last_path_len=2,
|
||||
out_path_hash_mode=1,
|
||||
direct_path="aa00bb00",
|
||||
direct_path_len=2,
|
||||
direct_path_hash_mode=1,
|
||||
)
|
||||
|
||||
with (
|
||||
@@ -185,9 +186,9 @@ class TestOutgoingDMBroadcast:
|
||||
await _insert_contact(
|
||||
pub_key,
|
||||
"Alice",
|
||||
last_path="aabb",
|
||||
last_path_len=1,
|
||||
out_path_hash_mode=0,
|
||||
direct_path="aabb",
|
||||
direct_path_len=1,
|
||||
direct_path_hash_mode=0,
|
||||
route_override_path="cc00dd00",
|
||||
route_override_len=2,
|
||||
route_override_hash_mode=1,
|
||||
|
||||
Reference in New Issue
Block a user