diff --git a/tests/Makefile.am b/tests/Makefile.am index 3d39b0ed1..773c9cbd3 100644 --- a/tests/Makefile.am +++ b/tests/Makefile.am @@ -154,6 +154,7 @@ check_PROGRAMS += \ test_etcp_simple_traffic \ test_pkt_normalizer_etcp \ test_broadcast \ + test_chat_group_isolation \ test_stcp_traffic \ test_etcp_100_packets \ test_topo_recovery \ @@ -879,6 +880,10 @@ test_broadcast_SOURCES = test_broadcast.c test_broadcast_CFLAGS = -I$(top_srcdir)/src -I$(top_srcdir)/lib test_broadcast_LDADD = $(LIBUTUN) $(CRYPTO_LIBS) $(COMMON_LIBS) +test_chat_group_isolation_SOURCES = test_chat_group_isolation.c test_membership.h test_net.h +test_chat_group_isolation_CFLAGS = -I$(top_srcdir)/src -I$(top_srcdir)/lib +test_chat_group_isolation_LDADD = $(LIBUTUN) $(CRYPTO_LIBS) $(COMMON_LIBS) + test_conn_mgr_SOURCES = test_conn_mgr.c test_conn_mgr_CFLAGS = -I$(top_srcdir)/src -I$(top_srcdir)/lib test_conn_mgr_LDADD = $(LIBUTUN) $(CRYPTO_LIBS) $(COMMON_LIBS) diff --git a/tests/test_chat_group_isolation.c b/tests/test_chat_group_isolation.c new file mode 100644 index 000000000..79532c051 --- /dev/null +++ b/tests/test_chat_group_isolation.c @@ -0,0 +1,239 @@ +/* Две CHAT-группы на одном настоящем ETCP: побочные эффекты после локального отзыва допуска. + * Пакеты атакующего идут через etcp_send, минуя только его исходящую авторизацию. */ +#include +#include +#include +#include +#include "utun_instance.h" +#include "broadcast.h" +#include "chat/member_sync.h" +#include "radio/radio.h" +#include "routing_layer/topo_group.h" +#include "routing_layer/topo_node_sqlite.h" +#include "routing_layer/conn_mgr_priv.h" +#include "routing_layer/etcp_router.h" +#include "routing_layer/route_crypto.h" +#include "transport_layer/etcp.h" +#include "transport_layer/etcp_api.h" +#include "../lib/debug_config.h" +#include "../lib/mem.h" +#include "test_net.h" +#include "test_membership.h" + +#define REQUIRE(expr) do { if (!(expr)) { DEBUG_ERROR(DEBUG_CATEGORY_DEBUG, "isolation setup: %s", #expr); return -1; } } while (0) + +static struct { + struct UASYNC* ua; + struct UTUN_INSTANCE* nodes[2]; + struct TOPO_GROUP* groups[2][2]; + struct ETCP_CONN* transport; + etcp_recv_fn receivers[256]; + unsigned broadcast[2], radio[2], observed, passed, failed; + uint8_t expected[1024]; + size_t expected_size; +} fixture; + +/* Лог содержит и проверяемый эффект, и результат; отрицательный тест не обрывает соседние проверки. */ +static void check(const char* name, int ok) { + if (ok) { fixture.passed++; DEBUG_INFO(DEBUG_CATEGORY_DEBUG, "PASS %s", name); } + else { fixture.failed++; DEBUG_ERROR(DEBUG_CATEGORY_DEBUG, "FAIL %s", name); } +} + +/* Копия наблюдаемого пакета сохраняется до отправки; настоящий dispatcher не заменяется заглушкой. */ +static void observe(struct ETCP_CONN* conn, struct ll_entry* entry) { + uint8_t service = entry->dgram[0]; + if (entry->len == fixture.expected_size && !memcmp(entry->dgram, fixture.expected, entry->len)) { + fixture.observed++; + DEBUG_INFO(DEBUG_CATEGORY_DEBUG, "isolation wire delivered service=%u peer=%016llx bytes=%u", service, + (unsigned long long)conn->peer_node_id, entry->len); + } + fixture.receivers[service](conn, entry); +} + +/* Считаем реальную выдачу данных пользовательскому callback группы. */ +static void broadcast_received(const uint8_t* uuid, const uint8_t* data, uint16_t len, void* arg) { + (void)uuid; (void)data; (void)len; + unsigned group = (unsigned)(uintptr_t)arg; + fixture.broadcast[group]++; + DEBUG_INFO(DEBUG_CATEGORY_DEBUG, "isolation broadcast callback group=%u count=%u", 111 + group, fixture.broadcast[group]); +} + +static void radio_received(struct UTUN_INSTANCE* inst, uint64_t group, uint64_t source, uint16_t stream, + uint16_t seq, uint8_t fin, const uint8_t* opus, int len, void* arg) { + (void)inst; (void)stream; (void)seq; (void)fin; (void)opus; (void)len; (void)arg; + if (group < 111 || group > 112) { DEBUG_ERROR(DEBUG_CATEGORY_DEBUG, "unexpected radio group=%llu", (unsigned long long)group); return; } + fixture.radio[group - 111]++; + DEBUG_INFO(DEBUG_CATEGORY_DEBUG, "isolation radio callback group=%llu source=%016llx count=%u", + (unsigned long long)group, (unsigned long long)source, fixture.radio[group - 111]); +} + +/* Дождаться не только wire, но и отложенной обработки router/service очередей. */ +static int inject(const void* wire, size_t size) { + REQUIRE(size <= sizeof(fixture.expected)); + struct ll_entry* entry = ll_alloc_lldgram(size); + REQUIRE(entry); + memcpy(entry->dgram, wire, size); entry->len = size; + memcpy(fixture.expected, wire, size); fixture.expected_size = size; + unsigned before = fixture.observed; + if (etcp_send(fixture.transport, entry)) { + queue_dgram_free(entry); queue_entry_free(entry); + DEBUG_ERROR(DEBUG_CATEGORY_DEBUG, "isolation injection failed bytes=%zu", size); return -1; + } + uint64_t deadline = get_time_tb() + 30000; + while (fixture.observed == before && get_time_tb() < deadline) uasync_poll(fixture.ua, 1); + REQUIRE(fixture.observed == before + 1); + for (unsigned i = 0; i < 20; i++) uasync_poll(fixture.ua, 1); + return 0; +} + +static int send_broadcast(unsigned group, unsigned id) { + uint8_t wire[28] = {ETCP_ID_BROADCAST}; uint64_t gid = 111 + group; + memcpy(wire + 1, &gid, 8); memcpy(wire + 9, &id, sizeof(id)); + wire[25] = 1; wire[27] = 0x5a; + return inject(wire, sizeof(wire)); +} + +static int send_radio(unsigned group, uint16_t stream) { + uint8_t wire[1 + RADIO_HDR_SIZE + 8 + 3] = {ETCP_ID_RADIO}; + uint8_t* payload = wire + 1; uint64_t gid = 111 + group, source = fixture.nodes[1]->node_id; + uint16_t size = 1; + memcpy(payload + RADIO_OFF_GROUP, &gid, 8); memcpy(payload + RADIO_OFF_SRC, &source, 8); + memcpy(payload + RADIO_OFF_STREAM, &stream, 2); + payload[RADIO_OFF_HOPS] = payload[RADIO_OFF_COUNT] = 1; + memcpy(payload + RADIO_OFF_HOPLIST, &source, 8); + memcpy(payload + RADIO_HDR_SIZE + 8, &size, 2); payload[RADIO_HDR_SIZE + 10] = 0x5a; + return inject(wire, sizeof(wire)); +} + +/* Подписанный запрос с настоящими эпохами уже открытого router-канала. */ +static int send_direct(unsigned group, uint32_t request, struct ETCP_ROUTER_CONN* receiver) { + struct UTUN_INSTANCE* source = fixture.nodes[1]; + struct SVC_ROUTE_HDR header = {.cmd = ETCP_ID_SVC_ROUTE, .group_id = 111 + group, + .src_node_id = source->node_id, .dst_node_id = fixture.nodes[0]->node_id, .svc_id = ETCP_RT_ID_CONN_MGR, + .reset_id = receiver->peer_reset_id, .peer_reset_id = receiver->reset_id, .seq = receiver->rx_seq}; + struct CM_DIRECT_REQ message = {ETCP_RT_ID_CONN_MGR, CM_SUBCMD_DIRECT_REQ, request}; + uint8_t plain[sizeof(header) + sizeof(message)]; + memcpy(plain, &header, sizeof(header)); memcpy(plain + sizeof(header), &message, sizeof(message)); + uint8_t* encoded = NULL; size_t size = 0; + REQUIRE(!route_crypto_encode(source, header.dst_node_id, plain, sizeof(plain), ROUTE_CRYPTO_SIGN, &encoded, &size)); + uint8_t wire[1024]; + if (size + 9 > sizeof(wire)) { u_free(encoded); DEBUG_ERROR(DEBUG_CATEGORY_DEBUG, "isolation encoded packet too large"); return -1; } + memcpy(wire, encoded, size); u_free(encoded); + memcpy(wire + size, &source->node_id, 8); wire[size + 8] = 1; + return inject(wire, size + 9); +} + +/* Подписанный состав обоих каналов одинаков; отзыв позже меняется только у получателя. */ +static int setup(void) { + struct SC_MYKEYS keys[2]; char public_keys[2][65], private_keys[2][65]; + for (unsigned i = 0; i < 2; i++) { + REQUIRE(sc_generate_keypair(&keys[i]) == SC_OK); + bytes_to_hex(keys[i].public_key, 32, public_keys[i], 65); + bytes_to_hex(keys[i].private_key, 32, private_keys[i], 65); + } + fixture.ua = uasync_create(); REQUIRE(fixture.ua); + for (unsigned i = 0; i < 2; i++) { + char config[2048], client[256] = ""; + if (i) snprintf(client, sizeof(client), "[client: peer]\npeer_public_key=%s\nlink=udp:127.0.0.1:0\n", public_keys[0]); + snprintf(config, sizeof(config), "[global]\nauto_sockets=no\nmy_public_key=%s\nmy_private_key=%s\n" + "[server: udp]\naddr=127.0.0.1:0\ntype=public\n[allowed_keys]\nallow_all=1\n" + "[chatserver]\ngroup_autoconnect=0\n%s", public_keys[i], private_keys[i], client); + fixture.nodes[i] = utun_instance_create_from_str(fixture.ua, config); REQUIRE(fixture.nodes[i]); + } + test_etcp_set_peer(fixture.nodes[1], "peer", fixture.nodes[0], "udp"); + for (unsigned i = 0; i < 2; i++) { + struct UTUN_INSTANCE* inst = fixture.nodes[i]; + REQUIRE(!utun_core_start(inst) && !member_sync_init(inst)); + for (unsigned group = 0; group < 2; group++) { + char channel[32]; snprintf(channel, sizeof(channel), "%u", 111 + group); + struct UTUN_INSTANCE* owner = fixture.nodes[0]; uint8_t signature[64]; + REQUIRE(sc_ed25519_sign(owner->my_ed25519_privkey, (const uint8_t*)channel, strlen(channel), signature) == SC_OK); + REQUIRE(!topo_node_sqlite_channel_put(inst->topo_sqlite_db, channel, "isolation", owner->node_id, + owner->my_keys.public_key, NULL, owner->my_ed25519_pubkey, NULL, signature)); + for (unsigned peer = 0; peer < 2; peer++) { + struct UTUN_INSTANCE* node = fixture.nodes[peer]; + REQUIRE(test_member_seed(inst, channel, node->my_keys.public_key, node->my_ed25519_pubkey, + node->my_ed25519_privkey, owner->my_ed25519_privkey, "{}", NULL, NULL) >= 0); + } + fixture.groups[i][group] = topo_groups_create_group(inst->topo_groups, 111 + group, TOPO_GROUP_TYPE_CHAT, channel); + REQUIRE(fixture.groups[i][group]); fixture.groups[i][group]->radio_active = 1; + } + } + uint64_t deadline = get_time_tb() + 50000; + for (;;) { + fixture.transport = instance_find_conn(fixture.nodes[1], fixture.nodes[0]->node_id); + if (fixture.transport && fixture.transport->links_up) break; + REQUIRE(get_time_tb() < deadline); uasync_poll(fixture.ua, 1); + } + for (unsigned i = 0; i < 2; i++) for (unsigned group = 0; group < 2; group++) { + struct ETCP_CONN* conn = instance_find_conn(fixture.nodes[i], fixture.nodes[1 - i]->node_id); REQUIRE(conn); + REQUIRE(!topo_group_new_conn(fixture.groups[i][group], conn)); + } + deadline = get_time_tb() + 50000; + for (;;) { + unsigned ready = 0; + for (unsigned i = 0; i < 2; i++) for (unsigned group = 0; group < 2; group++) + ready += topo_group_peer_ready(fixture.groups[i][group], fixture.nodes[1 - i]->node_id); + if (ready == 4) break; + REQUIRE(get_time_tb() < deadline); uasync_poll(fixture.ua, 1); + } + const uint8_t services[] = {ETCP_ID_BROADCAST, ETCP_ID_RADIO, ETCP_ID_SVC_ROUTE}; + for (unsigned i = 0; i < sizeof(services); i++) { + uint8_t service = services[i]; fixture.receivers[service] = fixture.nodes[0]->api_bindings.callbacks[service]; + REQUIRE(fixture.receivers[service]); fixture.nodes[0]->api_bindings.callbacks[service] = observe; + } + for (unsigned group = 0; group < 2; group++) broadcast_add_cbk(fixture.groups[0][group], broadcast_received, (void*)(uintptr_t)group); + radio_set_frame_cb(fixture.nodes[0], radio_received, NULL); + return 0; +} + +/* Отзыв без proactive cleanup проверяет сам вход: старый транспорт и сессии намеренно остаются живы. */ +static int run(void) { + struct UTUN_INSTANCE* server = fixture.nodes[0]; uint64_t peer = fixture.nodes[1]->node_id; + REQUIRE(!send_broadcast(1, 1) && !send_radio(1, 1)); + check("admitted peer receives BROADCAST and RADIO in B", fixture.broadcast[1] == 1 && fixture.radio[1] == 1); + struct CM_DIRECT_REQ warmup = {ETCP_RT_ID_CONN_MGR, CM_SUBCMD_DIRECT_REQ, 1}; + struct ll_entry* entry = ll_alloc_lldgram(1 + sizeof(warmup)); REQUIRE(entry); + entry->dgram[0] = ETCP_RT_ID_CONN_MGR; memcpy(entry->dgram + 1, &warmup, sizeof(warmup)); entry->len = 1 + sizeof(warmup); + REQUIRE(!etcp_route_send(fixture.nodes[1], 112, server->node_id, entry, 1, ROUTE_CRYPTO_SIGN)); + struct CONN_MGR* manager = fixture.groups[0][1]->conn_mgr; struct CONN_MGR_ENTRY* state = NULL; + uint64_t deadline = get_time_tb() + 30000; + do { uasync_poll(fixture.ua, 1); state = cm_find_entry(manager, peer); } + while ((!state || state->peer_reverse_request_id != 1) && get_time_tb() < deadline); + REQUIRE(state && state->peer_reverse_request_id == 1); + struct ETCP_ROUTER_CONN* route = etcp_router_conn_get(server, 112, peer, ETCP_RT_ID_CONN_MGR); + REQUIRE(route && route->peer_reset_id); + check("admitted DIRECT_REQ changes only B manager", !cm_find_entry(fixture.groups[0][0]->conn_mgr, peer)); + char sql[160]; snprintf(sql, sizeof(sql), "UPDATE peers_112 SET admitted=0 WHERE node_id=%lld", (long long)peer); + REQUIRE(sqlite3_exec(server->topo_sqlite_db, sql, NULL, NULL, NULL) == SQLITE_OK); + REQUIRE(topo_group_chat_allowed(server, 111, peer) && !topo_group_chat_allowed(server, 112, peer)); + DEBUG_INFO(DEBUG_CATEGORY_DEBUG, "isolation revoked group=112 peer=%016llx shared_transport=%p", (unsigned long long)peer, + (void*)fixture.transport); + REQUIRE(!send_broadcast(1, 2)); + check("revoked peer cannot deliver BROADCAST in B", fixture.broadcast[1] == 1); + REQUIRE(!send_radio(1, 2)); + check("revoked peer cannot deliver RADIO in B", fixture.radio[1] == 1); + REQUIRE(!send_direct(1, 2, route)); + state = cm_find_entry(manager, peer); + DEBUG_INFO(DEBUG_CATEGORY_DEBUG, "isolation CM side effect group=112 request=%u", state ? state->peer_reverse_request_id : 0); + check("revoked signed DIRECT_REQ cannot change B manager", !state || state->peer_reverse_request_id == 1); + REQUIRE(!send_broadcast(0, 3) && !send_radio(0, 3)); + check("neighbouring A and the same ETCP still work", fixture.broadcast[0] == 1 && fixture.radio[0] == 1 + && fixture.transport == instance_find_conn(fixture.nodes[1], server->node_id) && fixture.transport->links_up); + return 0; +} + +int main(void) { + debug_config_init(); debug_set_level(DEBUG_LEVEL_WARN); + debug_set_category_level(DEBUG_CATEGORY_DEBUG, DEBUG_LEVEL_INFO); + if (getenv("UTUN_TEST_DEBUG")) { + debug_set_category_level(DEBUG_CATEGORY_GENERAL, DEBUG_LEVEL_DEBUG); + debug_set_category_level(DEBUG_CATEGORY_ETCPROUTE, DEBUG_LEVEL_DEBUG); + debug_set_category_level(DEBUG_CATEGORY_BGP, DEBUG_LEVEL_INFO); + } + if (setup() || run()) fixture.failed++; + for (unsigned i = 0; i < 2; i++) if (fixture.nodes[i]) { member_sync_destroy(fixture.nodes[i]); utun_instance_destroy(fixture.nodes[i]); } + if (fixture.ua) uasync_destroy(fixture.ua, 0); + DEBUG_INFO(DEBUG_CATEGORY_DEBUG, "CHAT isolation: passed=%u failed=%u", fixture.passed, fixture.failed); + return fixture.failed ? 1 : 0; +}