Files
voice-cat/tests/test_tofu_flow.cpp

386 lines
16 KiB
C++
Raw Permalink Normal View History

feat(M4): Windows WinForms client, TOFU identity pinning, VAD threshold + always-on mode Core ABI extensions (voicecat.h): - vc_list_channels / vc_list_users / vc_list_user_streams — pull-based snapshot getters for the channel-tree and user-list UI; session_model_mu_ guards cross-thread reads - VC_EVENT_JOIN_RESULT / vc_join_channel — channel join with optional password - VC_EVENT_SERVER_IDENTITY + vc_confirm_server_identity — TOFU gate that blocks io_thread_ until the UI approves or rejects; pins TLS leaf-cert SHA-256 (not declared Ed25519) - vc_get_server_identity_display — Ed25519 fingerprint for human-readable display only - VC_INPUT_ALWAYS_ON = 2 in vc_input_mode — transmit unconditionally, no VAD gate - vc_set_vad_threshold — live RMS threshold update (0.0–1.0); EnergyVadProcessor stores it atomically so the audio RT path reads without a lock C++ implementation: - SessionModel::apply_snapshot / apply_channel_event fixed to populate parent_id, password_protected, and max_users (were permanently zeroed) - TlsContext::peer_cert_fingerprint — SHA-256 of peer leaf cert DER via mbedTLS - TofuStore split into peek (read-only) + pin (write) so first-connect only persists after user approval; tofu_store_path in vc_config for per-user pin file location - TcpAcceptor uses dual-stack IPv6+IPv4 fallback (fixes localhost → ::1 on Windows) - windows-client CMake preset: Release shared DLL, static MinGW runtime, no tools/tests - New C++ tests: test_channel_user_list_abi, test_tofu_flow (14/14 green) Windows client (clients/windows/ — .NET 10 WinForms): - VoiceCat.Interop: LibraryImport P/Invoke surface, UnmanagedCallersOnly callbacks, Channel<VoiceCatEvent> event delivery drained by 30ms WinForms Timer - VoiceCat.App: ConnectDialog (saved servers, DPAPI password storage), ServerIdentity- Dialog (TOFU first-connect / mismatch warning), MainForm (channel TreeView, user ListBox, RichTextBox chat, voice controls, device pickers, VAD/PTT/always-on mode, per-user gain/mute/NR tuning, VAD sensitivity TrackBar, level meter ProgressBar) - PttKeyCaptureDialog — focus-scoped PTT key capture (documented limitation) - PerUserTuningDialog — real-time gain/mute/NR applied to all of a user's streams - Accessibility: explicit AccessibleName/Description on every control, & mnemonics, Activity log ListBox as durable screen-reader record, AutomationNotification for curated live announcements Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-17 00:35:16 +02:00
/*
* 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 <cstdio>
#ifdef VOICECAT_HAS_NET
#include <atomic>
#include <chrono>
#include <condition_variable>
#include <cstring>
#include <filesystem>
#include <mutex>
#include <string>
#include <thread>
#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<GatedEventStore*>(user);
std::lock_guard lk(s->mu);
switch (ev->type) {
case VC_EVENT_SERVER_IDENTITY:
s->got_identity = true;
s->identity_status = static_cast<vc_tofu_status>(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<vc_result>(ev->result);
break;
default:
break;
}
s->cv.notify_all();
}
template <typename Pred>
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<voicecat::server::Server> 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<uint16_t> 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<voicecat::server::Server>(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;
}
#else // !VOICECAT_HAS_NET
int main() {
std::printf("tofu_flow: SKIP (VOICECAT_HAS_NET not defined)\n");
return 0;
}
#endif // VOICECAT_HAS_NET