From 377b054ab4682708f50e624d7fdbd517d5db3eb5 Mon Sep 17 00:00:00 2001 From: agessaman Date: Thu, 16 Jul 2026 20:37:30 -0700 Subject: [PATCH] fix(room_server): push the stored post timestamp with a random attempt nonce The pushed frame serialized the delivery wall-clock time instead of the post's own stored timestamp, and left the flag byte's low 2 attempt bits always zero. Firmware sends the stored post timestamp and ORs in a fresh random attempt so a retried post produces a different packet hash and ACK; with a fixed timestamp two retries within the same second would be byte-identical and dropped as mesh duplicates. The serialized timestamp now also equals the push_post_timestamp sync watermark, matching the firmware invariant. --- repeater/handler_helpers/room_server.py | 10 +- tests/test_handler_helpers_room_server.py | 117 ++++++++++++++++++++++ 2 files changed, 124 insertions(+), 3 deletions(-) diff --git a/repeater/handler_helpers/room_server.py b/repeater/handler_helpers/room_server.py index eda2e66..de3b85d 100644 --- a/repeater/handler_helpers/room_server.py +++ b/repeater/handler_helpers/room_server.py @@ -379,9 +379,13 @@ class RoomServer: ) return False # Skip this client for now - # Build message payload - timestamp = int(time.time()) - flags = TXT_TYPE_SIGNED_PLAIN << 2 # Include author prefix + # Build message payload. + # Timestamp is the post's own stored timestamp, not the delivery + # time (MyMesh.cpp:56 serializes the stored post's timestamp). + timestamp = int(post["post_timestamp"]) + # Low 2 bits carry a random attempt nonce so retries of the same + # post get a different packet hash/ACK (MyMesh.cpp:59-60). + flags = (TXT_TYPE_SIGNED_PLAIN << 2) | (secrets.randbits(8) & 0x03) # Author prefix (first 4 bytes of pubkey) author_pubkey = bytes.fromhex(post["author_pubkey"]) diff --git a/tests/test_handler_helpers_room_server.py b/tests/test_handler_helpers_room_server.py index 7908783..2754113 100644 --- a/tests/test_handler_helpers_room_server.py +++ b/tests/test_handler_helpers_room_server.py @@ -383,6 +383,123 @@ async def test_room_server_push_expected_ack_matches_firmware_signed_ack(): assert client_ack_crc == expected_ack_crc +@pytest.mark.asyncio +async def test_push_serializes_stored_post_timestamp_not_walltime(): + """The pushed frame's timestamp must be the post's own stored timestamp, + not the delivery wall-clock time (MyMesh.cpp:56 serializes the stored + post's timestamp, not `now`).""" + db = _FakeDB() + db.get_client_sync.return_value = {"push_failures": 0} + injector = AsyncMock(return_value=True) + rs = _make_room_server(db=db, injector=injector) + rs.global_limiter = SimpleNamespace(acquire=AsyncMock(), release=MagicMock()) + rs._handle_ack_received = AsyncMock() + + client = _FakeClient(pubkey=b"E" * 32) + post = { + "author_pubkey": (b"F" * 32).hex(), + "message_text": "payload", + "post_timestamp": 1000.0, + } + + packet = SimpleNamespace(path=bytearray(), path_len=0) + with ( + patch( + "repeater.handler_helpers.room_server.CryptoUtils.sha256", + return_value=b"\x01\x02\x03\x04abcd", + ), + patch( + "repeater.handler_helpers.room_server.PacketBuilder.create_datagram", + return_value=packet, + ) as create_datagram, + ): + ok = await rs.push_post_to_client(client, post) + + assert ok is True + plaintext = create_datagram.call_args.kwargs["plaintext"] + assert int.from_bytes(plaintext[0:4], "little") == 1000 + + +@pytest.mark.asyncio +async def test_push_attempt_bits_randomized_across_retries(): + """Each push must OR a fresh random byte's low 2 bits into flags so a + retried post gets a different packet hash (and ACK), per MyMesh.cpp:59-60 + ("so packet hash (and ACK) will be different").""" + db = _FakeDB() + db.get_client_sync.return_value = {"push_failures": 0} + injector = AsyncMock(return_value=True) + rs = _make_room_server(db=db, injector=injector) + rs.global_limiter = SimpleNamespace(acquire=AsyncMock(), release=MagicMock()) + rs._handle_ack_received = AsyncMock() + + client = _FakeClient(pubkey=b"E" * 32) + post = { + "author_pubkey": (b"F" * 32).hex(), + "message_text": "payload", + "post_timestamp": 1234.5, + } + + plaintexts = [] + acks = [] + with patch("repeater.handler_helpers.room_server.secrets.randbits", side_effect=[1, 2]): + for _ in range(2): + packet = SimpleNamespace(path=bytearray(), path_len=0) + with patch( + "repeater.handler_helpers.room_server.PacketBuilder.create_datagram", + return_value=packet, + ) as create_datagram: + ok = await rs.push_post_to_client(client, post) + assert ok is True + plaintexts.append(create_datagram.call_args.kwargs["plaintext"]) + acks.append(db.upsert_client_sync.call_args.kwargs["pending_ack_crc"]) + + flags = [pt[4] for pt in plaintexts] + assert (flags[0] >> 2) & 0x3F == TXT_TYPE_SIGNED_PLAIN + assert (flags[1] >> 2) & 0x3F == TXT_TYPE_SIGNED_PLAIN + assert flags[0] & 0x03 != flags[1] & 0x03 + assert plaintexts[0][0:4] == plaintexts[1][0:4] # timestamp unchanged + assert plaintexts[0][5:] == plaintexts[1][5:] # author prefix + text unchanged + assert acks[0] != acks[1] + + +@pytest.mark.asyncio +async def test_push_watermark_equals_serialized_timestamp(): + """The db sync watermark update must use the same timestamp value that + was serialized into the pushed plaintext.""" + db = _FakeDB() + db.get_client_sync.return_value = {"push_failures": 0} + injector = AsyncMock(return_value=True) + rs = _make_room_server(db=db, injector=injector) + rs.global_limiter = SimpleNamespace(acquire=AsyncMock(), release=MagicMock()) + rs._handle_ack_received = AsyncMock() + + client = _FakeClient(pubkey=b"E" * 32) + post = { + "author_pubkey": (b"F" * 32).hex(), + "message_text": "payload", + "post_timestamp": 555.0, + } + + packet = SimpleNamespace(path=bytearray(), path_len=0) + with ( + patch( + "repeater.handler_helpers.room_server.CryptoUtils.sha256", + return_value=b"\x01\x02\x03\x04abcd", + ), + patch( + "repeater.handler_helpers.room_server.PacketBuilder.create_datagram", + return_value=packet, + ) as create_datagram, + ): + ok = await rs.push_post_to_client(client, post) + + assert ok is True + plaintext = create_datagram.call_args.kwargs["plaintext"] + serialized_ts = int.from_bytes(plaintext[0:4], "little") + watermark = db.upsert_client_sync.call_args.kwargs["push_post_timestamp"] + assert int(watermark) == serialized_ts + + @pytest.mark.asyncio async def test_room_server_push_post_to_client_backoff_skip_and_timeout_path(): db = _FakeDB()