/* * test_tofu_flow — M4 TOFU server-identity gate (voicecat.h's VC_EVENT_SERVER_IDENTITY / * vc_confirm_server_identity / vc_get_server_identity_display). * * Needs a real server (in-process, like the other ABI tests) so a real TLS handshake * happens — pinning a fingerprint against a mock would prove nothing. * * 1. First connect to a fresh server blocks (no AUTH_RESULT) until * vc_confirm_server_identity() is called; then it proceeds normally. * 2. Rejecting (accept=0) disconnects with VC_ERR_CRYPTO and does NOT persist a pin — a * second attempt to the same server still reports FIRST_CONNECT. * 3. Reconnecting to a server with the SAME identity (same data_dir, restarted on the * same port) reports MATCHED. * 4. Reconnecting to a server with a DIFFERENT identity on the same host:port (key * rotation / MITM) reports MISMATCH. * 5. vc_confirm_server_identity with nothing pending returns VC_ERR_INVALID_ARG. * 6. vc_get_server_identity_display is empty pre-connect and populated (64 hex chars — * the raw, colon-free encoding of the Ed25519 fingerprint) after ServerHello. */ #include #include #include #include #include #include #include #include #include #include "voicecat.h" #include "server.h" // ── Event tracking — deliberately does NOT auto-confirm, so the test drives the gate ────── struct GatedEventStore { std::mutex mu; std::condition_variable cv; bool auth_ok{false}; bool got_identity{false}; vc_tofu_status identity_status{}; bool disconnected{false}; vc_result disconnect_result{VC_OK}; vc_client* client{nullptr}; }; static void on_event_gated(void* user, const vc_event* ev) { auto* s = static_cast(user); std::lock_guard lk(s->mu); switch (ev->type) { case VC_EVENT_SERVER_IDENTITY: s->got_identity = true; s->identity_status = static_cast(ev->u32a); break; case VC_EVENT_AUTH_RESULT: s->auth_ok = (ev->result == VC_OK); break; case VC_EVENT_DISCONNECTED: s->disconnected = true; s->disconnect_result = static_cast(ev->result); break; default: break; } s->cv.notify_all(); } template static bool wait_for(GatedEventStore& s, Pred pred, int timeout_ms) { auto deadline = std::chrono::steady_clock::now() + std::chrono::milliseconds(timeout_ms); std::unique_lock lk(s.mu); return s.cv.wait_until(lk, deadline, [&] { return pred(s); }); } // ── Test harness ────────────────────────────────────────────────────────────── static int g_failures = 0; #define CHECK(cond) \ do { \ if (!(cond)) { \ std::printf("FAIL: %s (%s:%d)\n", #cond, __FILE__, __LINE__); \ ++g_failures; \ } \ } while (0) // ── A small helper to start/stop an in-process server on a chosen (or OS-assigned) port ─── struct RunningServer { voicecat::server::Config cfg; std::unique_ptr server; std::thread server_thread; uint16_t port{0}; bool start(const std::string& data_dir, uint16_t want_port, const char* name) { std::atomic bound_port{0}; std::mutex ready_mu; std::condition_variable ready_cv; bool ready{false}; cfg.data_dir = data_dir; cfg.bind_port = want_port; cfg.media_port = 0; cfg.server_name = name; cfg.allow_guests = true; cfg.on_ready = [&](uint16_t p) { bound_port.store(p); { std::lock_guard lk(ready_mu); ready = true; } ready_cv.notify_all(); }; server = std::make_unique(cfg); server_thread = std::thread([this] { server->run(); }); std::unique_lock lk(ready_mu); bool ok = ready_cv.wait_for(lk, std::chrono::seconds(10), [&] { return ready; }); if (!ok) return false; port = bound_port.load(); return true; } void stop_and_join() { if (server) server->stop(); if (server_thread.joinable()) server_thread.join(); } }; // ── 1. First connect blocks until confirmed ───────────────────────────────────── static void test_first_connect_blocks(uint16_t port, const std::string& tofu_path) { GatedEventStore ev; vc_callbacks cb{on_event_gated, nullptr, &ev}; vc_config cfg{"test-gate", "0.1", VC_LOG_OFF}; cfg.tofu_store_path = tofu_path.c_str(); vc_client* c = vc_client_create(&cfg, cb); CHECK(c != nullptr); ev.client = c; CHECK(vc_connect(c, "127.0.0.1", port) == VC_OK); CHECK(vc_authenticate_guest(c, "Gated") == VC_OK); CHECK(wait_for(ev, [](GatedEventStore& s) { return s.got_identity; }, 5000)); { std::lock_guard lk(ev.mu); CHECK(ev.identity_status == VC_TOFU_FIRST_CONNECT); } // No confirmation yet — auth must NOT complete within a short window. CHECK(!wait_for(ev, [](GatedEventStore& s) { return s.auth_ok; }, 1000)); CHECK(vc_confirm_server_identity(c, 1) == VC_OK); CHECK(wait_for(ev, [](GatedEventStore& s) { return s.auth_ok; }, 5000)); vc_disconnect(c); vc_client_destroy(c); std::printf("test_first_connect_blocks: ok\n"); } // ── 2. Reject doesn't persist a pin ────────────────────────────────────────────── static void test_reject_does_not_persist(uint16_t port, const std::string& tofu_path) { { GatedEventStore ev; vc_callbacks cb{on_event_gated, nullptr, &ev}; vc_config cfg{"test-reject", "0.1", VC_LOG_OFF}; cfg.tofu_store_path = tofu_path.c_str(); vc_client* c = vc_client_create(&cfg, cb); CHECK(c != nullptr); ev.client = c; CHECK(vc_connect(c, "127.0.0.1", port) == VC_OK); CHECK(wait_for(ev, [](GatedEventStore& s) { return s.got_identity; }, 5000)); { std::lock_guard lk(ev.mu); CHECK(ev.identity_status == VC_TOFU_FIRST_CONNECT); } CHECK(vc_confirm_server_identity(c, 0) == VC_OK); CHECK(wait_for(ev, [](GatedEventStore& s) { return s.disconnected; }, 5000)); { std::lock_guard lk(ev.mu); CHECK(ev.disconnect_result == VC_ERR_CRYPTO); } vc_client_destroy(c); } // Second attempt to the SAME server, SAME pin file: still FIRST_CONNECT — the rejected // pin from above must not have been written to disk. { GatedEventStore ev; vc_callbacks cb{on_event_gated, nullptr, &ev}; vc_config cfg{"test-reject2", "0.1", VC_LOG_OFF}; cfg.tofu_store_path = tofu_path.c_str(); vc_client* c = vc_client_create(&cfg, cb); CHECK(c != nullptr); ev.client = c; CHECK(vc_connect(c, "127.0.0.1", port) == VC_OK); CHECK(wait_for(ev, [](GatedEventStore& s) { return s.got_identity; }, 5000)); { std::lock_guard lk(ev.mu); CHECK(ev.identity_status == VC_TOFU_FIRST_CONNECT); } vc_disconnect(c); vc_client_destroy(c); } std::printf("test_reject_does_not_persist: ok\n"); } // ── 3/4. MATCHED on identity reuse, MISMATCH on identity rotation ────────────── static void test_matched_and_mismatch(const std::string& tofu_path) { auto tmp = std::filesystem::temp_directory_path() / ("vctest_tofu_" + std::to_string( std::chrono::steady_clock::now().time_since_epoch().count())); std::filesystem::create_directories(tmp); auto data_dir_1 = (tmp / "server1").string(); // identity A auto data_dir_2 = (tmp / "server2").string(); // identity B (different) // ── Server 1 (identity A), first connect: accept + pin ────────────────────── RunningServer server1; CHECK(server1.start(data_dir_1, 0, "VoiceCat-TofuA")); uint16_t port = server1.port; std::printf("test_matched_and_mismatch: server1 ready on :%u\n", port); { GatedEventStore ev; vc_callbacks cb{on_event_gated, nullptr, &ev}; vc_config cfg{"test-pin", "0.1", VC_LOG_OFF}; cfg.tofu_store_path = tofu_path.c_str(); vc_client* c = vc_client_create(&cfg, cb); CHECK(c != nullptr); ev.client = c; CHECK(vc_connect(c, "127.0.0.1", port) == VC_OK); CHECK(vc_authenticate_guest(c, "Pin") == VC_OK); CHECK(wait_for(ev, [](GatedEventStore& s) { return s.got_identity; }, 5000)); { std::lock_guard lk(ev.mu); CHECK(ev.identity_status == VC_TOFU_FIRST_CONNECT); } CHECK(vc_confirm_server_identity(c, 1) == VC_OK); CHECK(wait_for(ev, [](GatedEventStore& s) { return s.auth_ok; }, 5000)); vc_disconnect(c); vc_client_destroy(c); } server1.stop_and_join(); // ── Server 1 restarted on the SAME port, SAME data_dir (identity A reloaded from disk // — ServerIdentityManager::init's load-existing-files path) — expect MATCHED. ────────── RunningServer server1_restarted; CHECK(server1_restarted.start(data_dir_1, port, "VoiceCat-TofuA")); { GatedEventStore ev; vc_callbacks cb{on_event_gated, nullptr, &ev}; vc_config cfg{"test-matched", "0.1", VC_LOG_OFF}; cfg.tofu_store_path = tofu_path.c_str(); vc_client* c = vc_client_create(&cfg, cb); CHECK(c != nullptr); ev.client = c; CHECK(vc_connect(c, "127.0.0.1", port) == VC_OK); CHECK(vc_authenticate_guest(c, "Matched") == VC_OK); CHECK(wait_for(ev, [](GatedEventStore& s) { return s.got_identity; }, 5000)); { std::lock_guard lk(ev.mu); CHECK(ev.identity_status == VC_TOFU_MATCHED); } CHECK(vc_confirm_server_identity(c, 1) == VC_OK); CHECK(wait_for(ev, [](GatedEventStore& s) { return s.auth_ok; }, 5000)); vc_disconnect(c); vc_client_destroy(c); } server1_restarted.stop_and_join(); // ── A DIFFERENT server (identity B, fresh data_dir) on the SAME port — expect // MISMATCH. Reject it, and confirm the pin file still reflects identity A afterwards. ─── RunningServer server2; CHECK(server2.start(data_dir_2, port, "VoiceCat-TofuB")); { GatedEventStore ev; vc_callbacks cb{on_event_gated, nullptr, &ev}; vc_config cfg{"test-mismatch", "0.1", VC_LOG_OFF}; cfg.tofu_store_path = tofu_path.c_str(); vc_client* c = vc_client_create(&cfg, cb); CHECK(c != nullptr); ev.client = c; CHECK(vc_connect(c, "127.0.0.1", port) == VC_OK); CHECK(wait_for(ev, [](GatedEventStore& s) { return s.got_identity; }, 5000)); { std::lock_guard lk(ev.mu); CHECK(ev.identity_status == VC_TOFU_MISMATCH); } CHECK(vc_confirm_server_identity(c, 0) == VC_OK); // reject the rotated identity CHECK(wait_for(ev, [](GatedEventStore& s) { return s.disconnected; }, 5000)); vc_client_destroy(c); } server2.stop_and_join(); // ── Server 1 (identity A) once more — rejecting the mismatch above must not have // clobbered the original pin. ─────────────────────────────────────────────────────────── RunningServer server1_again; CHECK(server1_again.start(data_dir_1, port, "VoiceCat-TofuA")); { GatedEventStore ev; vc_callbacks cb{on_event_gated, nullptr, &ev}; vc_config cfg{"test-still-matched", "0.1", VC_LOG_OFF}; cfg.tofu_store_path = tofu_path.c_str(); vc_client* c = vc_client_create(&cfg, cb); CHECK(c != nullptr); ev.client = c; CHECK(vc_connect(c, "127.0.0.1", port) == VC_OK); CHECK(wait_for(ev, [](GatedEventStore& s) { return s.got_identity; }, 5000)); { std::lock_guard lk(ev.mu); CHECK(ev.identity_status == VC_TOFU_MATCHED); } vc_disconnect(c); vc_client_destroy(c); } server1_again.stop_and_join(); std::filesystem::remove_all(tmp); std::printf("test_matched_and_mismatch: ok\n"); } // ── 5. confirm_server_identity with nothing pending ───────────────────────────── static void test_confirm_with_nothing_pending() { vc_config cfg{"test-nopending", "0.1", VC_LOG_OFF}; vc_callbacks cb{}; vc_client* c = vc_client_create(&cfg, cb); CHECK(c != nullptr); CHECK(vc_confirm_server_identity(c, 1) == VC_ERR_INVALID_ARG); vc_client_destroy(c); std::printf("test_confirm_with_nothing_pending: ok\n"); } // ── 6. vc_get_server_identity_display ─────────────────────────────────────────── static void test_get_server_identity_display(uint16_t port, const std::string& tofu_path) { vc_config cfg{"test-display", "0.1", VC_LOG_OFF}; cfg.tofu_store_path = tofu_path.c_str(); GatedEventStore ev; vc_callbacks cb{on_event_gated, nullptr, &ev}; vc_client* c = vc_client_create(&cfg, cb); CHECK(c != nullptr); ev.client = c; // Pre-connect: empty. size_t len = 12345; CHECK(vc_get_server_identity_display(c, nullptr, 0, &len) == VC_OK); CHECK(len == 0); CHECK(vc_connect(c, "127.0.0.1", port) == VC_OK); CHECK(vc_authenticate_guest(c, "Display") == VC_OK); CHECK(wait_for(ev, [](GatedEventStore& s) { return s.got_identity; }, 5000)); CHECK(vc_confirm_server_identity(c, 1) == VC_OK); CHECK(wait_for(ev, [](GatedEventStore& s) { return s.auth_ok; }, 5000)); char buf[256] = {}; CHECK(vc_get_server_identity_display(c, buf, sizeof(buf), &len) == VC_OK); CHECK(len == 64); // 32-byte Ed25519 fingerprint, raw hex, no colons CHECK(std::strlen(buf) == 64); vc_disconnect(c); vc_client_destroy(c); std::printf("test_get_server_identity_display: ok\n"); } int main() { test_confirm_with_nothing_pending(); auto tmp = std::filesystem::temp_directory_path() / ("vctest_tofu_main_" + std::to_string( std::chrono::steady_clock::now().time_since_epoch().count())); std::filesystem::create_directories(tmp); { RunningServer server; CHECK(server.start((tmp / "srv").string(), 0, "VoiceCat-TofuFlow")); uint16_t port = server.port; std::printf("test_tofu_flow: server ready on :%u\n", port); test_first_connect_blocks(port, (tmp / "pins_blocks.txt").string()); test_reject_does_not_persist(port, (tmp / "pins_reject.txt").string()); test_get_server_identity_display(port, (tmp / "pins_display.txt").string()); server.stop_and_join(); } test_matched_and_mismatch((tmp / "pins_matched.txt").string()); std::filesystem::remove_all(tmp); if (g_failures == 0) { std::printf("tofu_flow: all checks passed\n"); return 0; } std::printf("tofu_flow: %d failure(s)\n", g_failures); return 1; }