Pass 1 on PATH integration

This commit is contained in:
Jack Kingsman
2026-03-18 19:15:02 -07:00
parent b832239e22
commit 69e09378f5
48 changed files with 1353 additions and 412 deletions
+3 -3
View File
@@ -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;
+9 -7
View File
@@ -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):
+177
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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)])
+10 -9
View File
@@ -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,