diff --git a/.github/workflows/run-unit-tests.yml b/.github/workflows/run-unit-tests.yml index d60b29615..38bd1429f 100644 --- a/.github/workflows/run-unit-tests.yml +++ b/.github/workflows/run-unit-tests.yml @@ -217,6 +217,7 @@ jobs: python3 -B test/test_ota_device_deflate_role.py -v python3 -B test/test_client_acl_spiffs.py python3 -B test/test_client_acl_cli.py -v + python3 -B test/test_client_acl_response.py -v - name: Verify ESP32 USB reboot sleep grace and G3 button wake run: | diff --git a/examples/simple_repeater/MyMesh.cpp b/examples/simple_repeater/MyMesh.cpp index 9302743c1..84ed4feee 100644 --- a/examples/simple_repeater/MyMesh.cpp +++ b/examples/simple_repeater/MyMesh.cpp @@ -19,6 +19,7 @@ #include #endif #include +#include #include #include #include @@ -729,7 +730,12 @@ uint8_t MyMesh::handleAnonClockReq(const mesh::Identity& sender, uint32_t sender return 0; } -int MyMesh::handleRequest(ClientInfo *sender, uint32_t sender_timestamp, uint8_t *payload, size_t payload_len) { +int MyMesh::handleRequest(ClientInfo *sender, uint32_t sender_timestamp, uint8_t *payload, size_t payload_len, + size_t reply_capacity) { + if (sender == NULL || payload_len == 0 || reply_capacity < 4) return 0; + if (reply_capacity > mesh::CLIENT_ACL_DIRECT_REPLY_CAPACITY) { + reply_capacity = mesh::CLIENT_ACL_DIRECT_REPLY_CAPACITY; + } // uint32_t now = getRTCClock()->getCurrentTimeUnique(); // memcpy(reply_data, &now, 4); // response packets always prefixed with timestamp memcpy(reply_data, &sender_timestamp, 4); // reflect sender_timestamp back in response packet (kind of like a 'tag') @@ -780,12 +786,15 @@ int MyMesh::handleRequest(ClientInfo *sender, uint32_t sender_timestamp, uint8_t memcpy(&reply_data[4], telemetry.getBuffer(), tlen); return 4 + tlen; // reply_len } - if (payload[0] == REQ_TYPE_GET_ACCESS_LIST && sender->isAdmin()) { + if (payload[0] == REQ_TYPE_GET_ACCESS_LIST) { + if (!sender->isAdmin() || payload_len < 3) return 0; uint8_t res1 = payload[1]; // reserved for future (extra query params) uint8_t res2 = payload[2]; if (res1 == 0 && res2 == 0) { - uint8_t ofs = 4; - for (int i = 0; i < acl.getNumClients() && ofs + 7 <= sizeof(reply_data) - 4; i++) { + size_t ofs = 4; + // Legacy ACL replies contain no pagination or total-count field. Keep + // the same bounded prefix, but fit the actual encrypted reply route. + for (int i = 0; i < acl.getNumClients() && ofs + 7 <= reply_capacity; i++) { auto c = acl.getClientByIdx(i); if (c->permissions == 0) continue; // skip deleted entries memcpy(&reply_data[ofs], c->id.pub_key, 6); ofs += 6; // just 6-byte pub_key prefix @@ -965,15 +974,15 @@ static bool directPathsEqual(const uint8_t* a_path, uint8_t a_len, const uint8_t return byte_len == 0 || memcmp(a_path, b_path, byte_len) == 0; } -void MyMesh::sendClientReply(ClientInfo* client, mesh::Packet* packet, unsigned long delay_millis, uint8_t path_hash_size) { +bool MyMesh::sendClientReply(ClientInfo* client, mesh::Packet* packet, unsigned long delay_millis, uint8_t path_hash_size) { TransportKey fallback_scope; const TransportKey* fallback_scope_ptr = NULL; if (recv_pkt_region != NULL && !recv_pkt_region->isWildcard() && region_map.getTransportKeysFor(*recv_pkt_region, &fallback_scope, 1) > 0) { fallback_scope_ptr = &fallback_scope; } - sendClientReplyWithFallbackScope(client, packet, delay_millis, path_hash_size, - fallback_scope_ptr); + return sendClientReplyWithFallbackScope(client, packet, delay_millis, path_hash_size, + fallback_scope_ptr); } bool MyMesh::sendClientReplyWithFallbackScope(ClientInfo* client, mesh::Packet* packet, @@ -2826,25 +2835,30 @@ void MyMesh::onPeerDataRecv(mesh::Packet *packet, uint8_t type, int sender_idx, #endif if (type == PAYLOAD_TYPE_REQ) { // request (from a Known admin client!) + if (len < 5) return; // timestamp plus request type must be present uint32_t timestamp; memcpy(×tamp, data, 4); if (timestamp > client->last_timestamp) { // prevent replay attacks - int reply_len = handleRequest(client, timestamp, &data[4], len - 4); + const size_t reply_capacity = + mesh::clientACLReplyCapacity(packet->isRouteFlood(), packet->path_len); + int reply_len = handleRequest(client, timestamp, &data[4], len - 4, reply_capacity); if (reply_len == 0) return; // invalid command - client->last_timestamp = timestamp; - client->last_activity = getRTCClock()->getCurrentTime(); - + bool reply_queued = false; if (packet->isRouteFlood()) { // let this sender know path TO here, so they can use sendDirect(), and ALSO encode the response mesh::Packet *path = createPathReturn(client->id, secret, packet->path, packet->path_len, PAYLOAD_TYPE_RESPONSE, reply_data, reply_len); - if (path) sendFloodReply(path, SERVER_RESPONSE_DELAY, packet->getPathHashSize()); + if (path) reply_queued = sendFloodReply(path, SERVER_RESPONSE_DELAY, packet->getPathHashSize()); } else { mesh::Packet *reply = createDatagram(PAYLOAD_TYPE_RESPONSE, client->id, secret, reply_data, reply_len); - sendClientReply(client, reply, SERVER_RESPONSE_DELAY, packet->getPathHashSize()); + reply_queued = sendClientReply(client, reply, SERVER_RESPONSE_DELAY, packet->getPathHashSize()); + } + if (reply_queued) { + client->last_timestamp = timestamp; + client->last_activity = getRTCClock()->getCurrentTime(); } } else { MESH_DEBUG_PRINTLN("onPeerDataRecv: possible replay attack detected"); diff --git a/examples/simple_repeater/MyMesh.h b/examples/simple_repeater/MyMesh.h index 1d0b35c86..9e2ca7370 100644 --- a/examples/simple_repeater/MyMesh.h +++ b/examples/simple_repeater/MyMesh.h @@ -733,7 +733,8 @@ class MyMesh : public mesh::Mesh, public CommonCLICallbacks uint8_t handleAnonRegionsReq(const mesh::Identity& sender, uint32_t sender_timestamp, const uint8_t* data); uint8_t handleAnonOwnerReq(const mesh::Identity& sender, uint32_t sender_timestamp, const uint8_t* data); uint8_t handleAnonClockReq(const mesh::Identity& sender, uint32_t sender_timestamp, const uint8_t* data); - int handleRequest(ClientInfo* sender, uint32_t sender_timestamp, uint8_t* payload, size_t payload_len); + int handleRequest(ClientInfo* sender, uint32_t sender_timestamp, uint8_t* payload, size_t payload_len, + size_t reply_capacity = MAX_PACKET_PAYLOAD - CIPHER_MAC_SIZE - (CIPHER_BLOCK_SIZE - 1)); mesh::Packet* createSelfAdvert(); void processDeferredCliCommand(); void clearDeferredCliCommand(); @@ -1003,7 +1004,7 @@ protected: // OTA mesh-integration is centralized in mesh::Mesh (no per-example onOtaRecv / send adapter / tick). bool sendFloodReply(mesh::Packet* packet, unsigned long delay_millis, uint8_t path_hash_size); - void sendClientReply(ClientInfo* client, mesh::Packet* packet, unsigned long delay_millis, uint8_t path_hash_size); + bool sendClientReply(ClientInfo* client, mesh::Packet* packet, unsigned long delay_millis, uint8_t path_hash_size); public: MyMesh(mesh::MainBoard& board, mesh::Radio& radio, mesh::MillisecondClock& ms, mesh::RNG& rng, mesh::RTCClock& rtc, mesh::MeshTables& tables); diff --git a/src/helpers/ClientACLResponse.h b/src/helpers/ClientACLResponse.h new file mode 100644 index 000000000..b9a68f350 --- /dev/null +++ b/src/helpers/ClientACLResponse.h @@ -0,0 +1,26 @@ +#pragma once + +#include +#include "DatagramPayloadLimits.h" + +namespace mesh { + +// Match createDatagram() and createPathReturn() admission limits. A flood +// request returns its observed path inside the encrypted response, so those +// bytes must also be reserved before adding legacy seven-byte ACL entries. +static constexpr size_t CLIENT_ACL_DIRECT_REPLY_CAPACITY = + DatagramPayloadLimits::maxPlaintext( + MAX_PACKET_PAYLOAD, CIPHER_MAC_SIZE, CIPHER_BLOCK_SIZE); + +inline size_t clientACLReplyCapacity(bool flood, uint8_t path_len) { + if (!Packet::isValidPathLen(path_len)) return 0; + if (!flood) return CLIENT_ACL_DIRECT_REPLY_CAPACITY; + const size_t path_bytes = (path_len & 63) * ((path_len >> 6) + 1); + // createPathReturn(): path_bytes + extra_len + 5 <= + // MAX_PACKET_PAYLOAD - 2 - CIPHER_BLOCK_SIZE. + const size_t fixed_bytes = 2 + CIPHER_BLOCK_SIZE + 5; + return path_bytes + fixed_bytes <= MAX_PACKET_PAYLOAD + ? MAX_PACKET_PAYLOAD - path_bytes - fixed_bytes : 0; +} + +} // namespace mesh diff --git a/test/fixtures/client_acl_response/test_client_acl_response.cpp b/test/fixtures/client_acl_response/test_client_acl_response.cpp new file mode 100644 index 000000000..2d250e7e2 --- /dev/null +++ b/test/fixtures/client_acl_response/test_client_acl_response.cpp @@ -0,0 +1,227 @@ +#include +#include +#include +#include +#include +#include + +#define REQ_TYPE_GET_ACCESS_LIST 0x05 +#define SERVER_RESPONSE_DELAY 300 + +namespace mesh { +struct Identity { + uint8_t pub_key[PUB_KEY_SIZE] = {}; + size_t copyHashTo(uint8_t* destination) const { + *destination = pub_key[0]; + return PATH_HASH_SIZE; + } +}; +struct Utils { + // Keep packet assembly/admission real; replace only the crypto primitive. + static int encryptThenMAC(const uint8_t*, uint8_t* destination, + const uint8_t* source, int length) { + const int encrypted = (length + CIPHER_BLOCK_SIZE - 1) / CIPHER_BLOCK_SIZE * CIPHER_BLOCK_SIZE; + memset(destination, 0, encrypted + CIPHER_MAC_SIZE); + memcpy(destination + CIPHER_MAC_SIZE, source, length); + return encrypted + CIPHER_MAC_SIZE; + } +}; +struct Random { + void random(uint8_t* data, size_t length) { memset(data, 0x99, length); } +}; +class Mesh { + std::vector allocations; +public: + Identity self_id; + Random rng; + bool refuse_allocation = false; + ~Mesh() { for (auto* packet : allocations) delete packet; } + Packet* obtainNewPacket() { + if (refuse_allocation) return nullptr; + auto* packet = new Packet; + allocations.push_back(packet); + return packet; + } + uint8_t getContactTxRadio(const Identity&) { return RADIO_TX_AUTO; } + Random* getRNG() { return &rng; } + Packet* createPathReturn(const Identity&, const uint8_t*, const uint8_t*, uint8_t, + uint8_t, const uint8_t*, size_t); + Packet* createPathReturn(const uint8_t*, const uint8_t*, const uint8_t*, uint8_t, + uint8_t, const uint8_t*, size_t); + Packet* createDatagram(uint8_t, const Identity&, const uint8_t*, const uint8_t*, size_t); +}; +} + +struct ClientInfo { + mesh::Identity id; + uint8_t permissions = 3; + uint32_t last_timestamp = 0, last_activity = 0; + bool isAdmin() const { return (permissions & 7) == 3; } +}; +struct ACL { + std::vector clients; + int getNumClients() const { return clients.size(); } + ClientInfo* getClientByIdx(int index) { return &clients.at(index); } +}; +struct Clock : mesh::RTCClock { + uint32_t now = 100; + uint32_t getCurrentTime() override { return now; } + void setCurrentTime(uint32_t value) override { now = value; } +}; +class MyMesh : public mesh::Mesh { +public: + uint8_t reply_data[MAX_PACKET_PAYLOAD] = {}; + ACL acl; + ClientInfo sender; + Clock clock; + bool refuse_queue = false; + unsigned queued = 0; + mesh::Packet* last_reply = nullptr; + Clock* getRTCClock() { return &clock; } + int handleRequest(ClientInfo*, uint32_t, uint8_t*, size_t, + size_t = mesh::CLIENT_ACL_DIRECT_REPLY_CAPACITY); + void receive(mesh::Packet*, uint8_t*, size_t); + bool admit(mesh::Packet* packet) { + if (!packet || refuse_queue) return false; + assert(packet->payload_len <= MAX_PACKET_PAYLOAD); + last_reply = packet; + ++queued; + return true; + } + bool sendFloodReply(mesh::Packet* packet, unsigned long, uint8_t) { return admit(packet); } + bool sendClientReply(ClientInfo*, mesh::Packet* packet, unsigned long, uint8_t) { return admit(packet); } +}; + +#include "production.inc" + +static void fill(ACL& acl, unsigned count, bool sparse = false) { + acl.clients.assign(count, ClientInfo{}); + for (unsigned i = 0; i < count; ++i) { + for (unsigned j = 0; j < PUB_KEY_SIZE; ++j) acl.clients[i].id.pub_key[j] = (i + j) % 255 + 1; + if (sparse && i % 3 != 1) acl.clients[i].permissions = 0; + } +} + +static unsigned verify_entries(MyMesh& mesh, int length, size_t capacity) { + assert(length >= 4 && size_t(length) <= capacity && (length - 4) % 7 == 0); + uint32_t tag; + memcpy(&tag, mesh.reply_data, 4); + assert(tag == 51); + unsigned offset = 4; + for (const auto& client : mesh.acl.clients) { + if (!client.permissions) continue; + if (offset + 7 > size_t(length)) break; + assert(!memcmp(mesh.reply_data + offset, client.id.pub_key, 6)); + assert(mesh.reply_data[offset + 6] == client.permissions); + offset += 7; + } + assert(offset == unsigned(length)); + return (length - 4) / 7; +} + +static void print_legacy(const uint8_t* body, unsigned count, size_t length) { + static const char hex[] = "0123456789abcdef"; + std::cout << "LEGACY:" << count << ':'; + for (size_t i = 0; i < length; ++i) std::cout << hex[body[i] >> 4] << hex[body[i] & 15]; + std::cout << '\n'; +} + +int main() { + uint8_t query[] = {REQ_TYPE_GET_ACCESS_LIST, 0, 0}; + uint8_t secret[PUB_KEY_SIZE] = {}; + unsigned paths = 0; + for (bool flood : {false, true}) { + for (unsigned path_len = 0; path_len < 256; ++path_len) { + if (!mesh::Packet::isValidPathLen(path_len)) { + assert(mesh::clientACLReplyCapacity(flood, path_len) == 0); + continue; + } + ++paths; + for (unsigned clients : {0, 1, 22, 23, 24, 25, 32, 256}) { + for (bool sparse : {false, true}) { + MyMesh mesh; + fill(mesh.acl, clients, sparse); + const size_t capacity = mesh::clientACLReplyCapacity(flood, path_len); + const int length = mesh.handleRequest(&mesh.sender, 51, query, sizeof(query), capacity); + const unsigned count = verify_entries(mesh, length, capacity); + const unsigned live = std::count_if(mesh.acl.clients.begin(), mesh.acl.clients.end(), + [](const ClientInfo& client) { return client.permissions != 0; }); + assert(count == std::min(live, unsigned((capacity - 4) / 7))); + uint8_t path[MAX_PATH_SIZE] = {}; + mesh::Packet* packet = flood + ? mesh.createPathReturn(mesh.sender.id, secret, path, path_len, + PAYLOAD_TYPE_RESPONSE, mesh.reply_data, length) + : mesh.createDatagram(PAYLOAD_TYPE_RESPONSE, mesh.sender.id, secret, mesh.reply_data, length); + assert(packet && packet->payload_len <= MAX_PACKET_PAYLOAD); + if (path_len == 0 && !sparse) { + // Decode the actual zero-padded encrypted response body, with the + // reflected tag and flood path envelope removed by Companion. + const size_t body_offset = 2 * PATH_HASH_SIZE + CIPHER_MAC_SIZE + 4 + (flood ? 2 : 0); + print_legacy(packet->payload + body_offset, count, packet->payload_len - body_offset); + } + } + } + } + } + assert(paths > 200); // Includes zero hops, 63 hops, 64 bytes and 2/3-byte hashes. + + MyMesh mesh; + fill(mesh.acl, 32); + for (uint8_t permission : {0, 1, 2, 4, 5, 6, 7, 255}) { + mesh.sender.permissions = permission; + assert(mesh.handleRequest(&mesh.sender, 51, query, sizeof(query)) == 0); + } + mesh.sender.permissions = 3; + uint8_t app_query[] = {REQ_TYPE_GET_ACCESS_LIST, 0, 0, 0xA1, 0xB2, 0xC3, 0xD4}; + const int app_reply = mesh.handleRequest(&mesh.sender, 51, app_query, sizeof(app_query)); + assert(verify_entries(mesh, app_reply, mesh::CLIENT_ACL_DIRECT_REPLY_CAPACITY) == 23); + assert(mesh.handleRequest(nullptr, 51, query, sizeof(query)) == 0); + for (size_t length : {0, 1, 2}) assert(mesh.handleRequest(&mesh.sender, 51, query, length) == 0); + for (unsigned index : {1, 2}) { + query[index] = 1; + assert(mesh.handleRequest(&mesh.sender, 51, query, sizeof(query)) == 0); + query[index] = 0; + } + for (size_t capacity = 0; capacity <= MAX_PACKET_PAYLOAD + 10; ++capacity) { + const int length = mesh.handleRequest(&mesh.sender, 51, query, sizeof(query), capacity); + if (capacity < 4) assert(length == 0); + else verify_entries(mesh, length, std::min(capacity, mesh::CLIENT_ACL_DIRECT_REPLY_CAPACITY)); + } + + // The exact receive branch keeps failed allocation/queue attempts retryable. + for (bool flood : {false, true}) { + MyMesh target; + fill(target.acl, 32); + target.sender.last_timestamp = 50; + target.sender.last_activity = 12; + mesh::Packet request; + request.header = flood ? ROUTE_TYPE_FLOOD : ROUTE_TYPE_DIRECT; + request.path_len = 0x60; // 32 two-byte hops: maximum 64 route bytes. + uint8_t data[] = {51, 0, 0, 0, REQ_TYPE_GET_ACCESS_LIST, 0, 0}; + for (size_t length = 0; length < 7; ++length) target.receive(&request, data, length); + assert(target.sender.last_timestamp == 50 && target.queued == 0); + target.refuse_allocation = true; + target.receive(&request, data, sizeof(data)); + assert(target.sender.last_timestamp == 50 && target.sender.last_activity == 12); + target.refuse_allocation = false; + target.refuse_queue = true; + target.receive(&request, data, sizeof(data)); + assert(target.sender.last_timestamp == 50 && target.sender.last_activity == 12); + target.refuse_queue = false; + target.receive(&request, data, sizeof(data)); + assert(target.sender.last_timestamp == 51 && target.sender.last_activity == 100 && target.queued == 1); + target.receive(&request, data, sizeof(data)); + data[0] = 49; + target.receive(&request, data, sizeof(data)); + assert(target.sender.last_timestamp == 51 && target.queued == 1); + } + + // Login and the next request use the same production monotonic RTC helper. + Clock clock; + const uint32_t login_tag = clock.getCurrentTimeUnique(); + const uint32_t request_tag = clock.getCurrentTimeUnique(); + assert(request_tag > login_tag); // Same-second login does not collide. + clock.setCurrentTime(10); + assert(clock.getCurrentTimeUnique() > request_tag); // In-boot clock rollback remains monotonic. + std::cout << "ACL route and replay checks passed\n"; +} diff --git a/test/test_client_acl_response.py b/test/test_client_acl_response.py new file mode 100644 index 000000000..0b0f34791 --- /dev/null +++ b/test/test_client_acl_response.py @@ -0,0 +1,105 @@ +#!/usr/bin/env python3 +"""Execute legacy ACL replies through the production route and replay guards.""" + +from pathlib import Path +import shutil +import subprocess +import sys +import tempfile +import unittest + +from test_replay_reset_integration import extract_braced + +ROOT = Path(__file__).resolve().parents[1] +FIXTURE = ROOT / "test/fixtures/client_acl_response" + +# Unmodified official decoder, meshcore-dev/meshcore_py commit +# 1e6fdd47a36ae32c30246a8486b7a9492665579f, src/meshcore/parsing.py (MIT). +# https://github.com/meshcore-dev/meshcore_py/blob/1e6fdd47a36ae32c30246a8486b7a9492665579f/src/meshcore/parsing.py +def parse_acl(buf): + i = 0 + res = [] + while i + 7 <= len(buf): + key = buf[i : i + 6].hex() + perm = buf[i + 6] + if key != "000000000000": + res.append({"key": key, "perm": perm}) + i = i + 7 + return res + + +class ClientAclResponseTest(unittest.TestCase): + def test_production_acl_route_admission_and_replay(self): + compiler = shutil.which("g++") or shutil.which("clang++") + if compiler is None: + self.skipTest("a host C++17 compiler is required") + source = (ROOT / "examples/simple_repeater/MyMesh.cpp").read_text() + handler = extract_braced(source, "int MyMesh::handleRequest(") + prefix = handler[:handler.index(" if (payload[0] == REQ_TYPE_GET_STATUS)")] + acl = extract_braced(handler, "if (payload[0] == REQ_TYPE_GET_ACCESS_LIST)") + generated = prefix + acl + "\nreturn 0;\n}\n" + receive = extract_braced(source, "if (type == PAYLOAD_TYPE_REQ) { // request") + generated += """ +void MyMesh::receive(mesh::Packet* packet, uint8_t* data, size_t len) { + ClientInfo* client = &sender; + const uint8_t type = PAYLOAD_TYPE_REQ; + uint8_t secret[PUB_KEY_SIZE] = {}; +""" + receive + "\n}\n" + mesh_source = (ROOT / "src/Mesh.cpp").read_text() + generated += "namespace mesh {\n" + generated += "#define MAX_COMBINED_PATH (MAX_PACKET_PAYLOAD - 2 - CIPHER_BLOCK_SIZE)\n" + for signature in ( + "Packet* Mesh::createPathReturn(const Identity& dest,", + "Packet* Mesh::createPathReturn(const uint8_t* dest_hash,", + "Packet* Mesh::createDatagram(", + ): + generated += extract_braced(mesh_source, signature) + "\n" + packet_source = (ROOT / "src/Packet.cpp").read_text() + for signature in ("Packet::Packet()", "bool Packet::isValidPathLen("): + generated += extract_braced(packet_source, signature) + "\n" + generated += "}\n" + with tempfile.TemporaryDirectory(prefix=".tmp-acl-response-", dir=ROOT) as directory: + work = Path(directory) + (work / "production.inc").write_text(generated, encoding="ascii") + binary = work / "acl-response.exe" + command = [compiler, "-std=c++17", "-Wall", "-Wextra", "-Werror", "-Wno-unused-parameter", + f"-I{work}", f"-I{ROOT / 'src'}", + str(FIXTURE / "test_client_acl_response.cpp"), "-o", str(binary)] + if sys.platform.startswith("linux"): + command[1:1] = ["-fsanitize=address,undefined", "-fno-sanitize-recover=all", + "-fno-pie", "-no-pie"] + built = subprocess.run(command, capture_output=True, text=True, timeout=60) + self.assertEqual(built.returncode, 0, built.stdout + built.stderr) + checked = subprocess.run([str(binary)], capture_output=True, text=True, timeout=10) + self.assertEqual(checked.returncode, 0, checked.stdout + checked.stderr) + self.assertIn("ACL route and replay checks passed", checked.stdout) + decoded = 0 + for line in checked.stdout.splitlines(): + if not line.startswith("LEGACY:"): + continue + _, expected, body = line.split(":") + result = parse_acl(bytes.fromhex(body)) + self.assertEqual(len(result), int(expected)) + self.assertTrue(all(entry["perm"] == 3 for entry in result)) + decoded += 1 + self.assertGreaterEqual(decoded, 10) + + def test_admission_result_and_default_capacity_wiring(self): + source = (ROOT / "examples/simple_repeater/MyMesh.cpp").read_text() + header = (ROOT / "examples/simple_repeater/MyMesh.h").read_text() + wrapper = extract_braced(source, "bool MyMesh::sendClientReply(") + self.assertIn("return sendClientReplyWithFallbackScope(", wrapper) + self.assertIn("bool sendClientReply(", header) + self.assertIn("size_t reply_capacity = MAX_PACKET_PAYLOAD - CIPHER_MAC_SIZE - (CIPHER_BLOCK_SIZE - 1)", header) + self.assertIn("if (len < 5) return;", source) + + def test_same_clock_generates_login_and_binary_request_tags(self): + source = (ROOT / "src/helpers/BaseChatMesh.cpp").read_text() + for signature in ("int BaseChatMesh::sendLogin(", + "int BaseChatMesh::sendRequest(const ContactInfo& recipient, const uint8_t*"): + body = extract_braced(source, signature) + self.assertEqual(body.count("getRTCClock()->getCurrentTimeUnique()"), 1) + + +if __name__ == "__main__": + unittest.main()