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>
This commit is contained in:
2026-06-17 00:35:16 +02:00
parent 5be869c61a
commit 63b241cc2e
56 changed files with 4685 additions and 35 deletions

View File

@@ -5,6 +5,24 @@ file(GLOB_RECURSE VOICECAT_SOURCES CONFIGURE_DEPENDS
if(VOICECAT_BUILD_SHARED)
add_library(voicecat SHARED ${VOICECAT_SOURCES})
if(WIN32 AND MINGW)
# M4: the C# client only ships voicecat.dll itself — no MinGW runtime DLLs alongside
# it. x64-mingw-static only statically links vcpkg's OWN library deps (protobuf,
# sodium, mbedTLS, ...); the GCC/MinGW runtime stays dynamic by default
# (libgcc_s_seh-1.dll/libwinpthread-1.dll/libstdc++-6.dll — confirmed via `objdump -p`
# on the existing vccli.exe). These flags are the standard fully-static-MinGW
# recipe. Verify after building (see clients/windows/README.md):
# objdump -p build/windows-client/bin/voicecat.dll | grep "DLL Name"
# should show only Windows system DLLs.
target_link_options(voicecat PRIVATE
-static-libgcc -static-libstdc++ -static -lwinpthread)
endif()
if(WIN32)
# CMake's default SHARED naming on MinGW adds a "lib" prefix (libvoicecat.dll) —
# drop it so the output is exactly voicecat.dll, matching the C ABI/library name the
# C# [LibraryImport] surface and docs use everywhere else.
set_target_properties(voicecat PROPERTIES PREFIX "")
endif()
else()
add_library(voicecat STATIC ${VOICECAT_SOURCES})
# Static consumers must see VC_API as empty (no dllimport).

View File

@@ -83,6 +83,10 @@ typedef enum vc_connection_state {
VC_STATE_TLS_HANDSHAKE = 2,
VC_STATE_AUTHENTICATING = 3,
VC_STATE_CONNECTED = 4,
/* M4: between TLS_HANDSHAKE and AUTHENTICATING — the handshake succeeded and the core is
* waiting for vc_confirm_server_identity() (see VC_EVENT_SERVER_IDENTITY below). Appended
* at the end (not inserted) to keep existing enum values stable — additive-only ABI. */
VC_STATE_VERIFYING_IDENTITY = 5,
} vc_connection_state;
typedef enum vc_text_scope {
@@ -106,6 +110,8 @@ typedef enum vc_stream_kind {
typedef enum vc_input_mode {
VC_INPUT_VOICE_ACTIVATION = 0,
VC_INPUT_PUSH_TO_TALK = 1,
/* Transmit unconditionally — no VAD gate. Added at the end to keep existing values stable. */
VC_INPUT_ALWAYS_ON = 2,
} vc_input_mode;
typedef enum vc_event_type {
@@ -121,8 +127,32 @@ typedef enum vc_event_type {
VC_EVENT_TALK_STATE = 9, /* user_id, stream_id, u32a = talking(0/1) */
VC_EVENT_ERROR = 10, /* result, text */
VC_EVENT_DISCONNECTED = 11, /* result, text = reason */
/* M4 additions — appended, not inserted, to keep existing values stable. */
VC_EVENT_JOIN_RESULT = 12, /* result (VC_OK/VC_ERR_*), channel_id, text = error on
failure. Reply to vc_join_channel(). */
VC_EVENT_SERVER_IDENTITY = 13, /* u32a = vc_tofu_status, text = hex-encoded TLS leaf-cert
SHA-256 fingerprint (the value being pinned — see
vc_confirm_server_identity). Emitted once per connect
attempt, right after the TLS handshake succeeds. The
connection is held open until vc_confirm_server_identity()
is called. */
} vc_event_type;
/* TOFU server-identity classification (M4) — see VC_EVENT_SERVER_IDENTITY and
* vc_confirm_server_identity. Pins the TLS leaf certificate's own SHA-256 fingerprint
* (verifiable directly from the handshake), NOT the declared Ed25519
* server_identity_fingerprint from ServerHello — the TLS cert and the server's Ed25519
* identity key are generated independently with no cryptographic binding between them today
* (docs/security.md §1.1), so pinning the self-declared value would be circular. The Ed25519
* fingerprint is still available for human-readable display via
* vc_get_server_identity_display(), it just isn't the value this gate accepts/rejects on. */
typedef enum vc_tofu_status {
VC_TOFU_FIRST_CONNECT = 0, /* no pin on file yet for this host:port */
VC_TOFU_MATCHED = 1, /* matches the previously pinned fingerprint */
VC_TOFU_MISMATCH = 2, /* DIFFERENT from the pinned fingerprint — possible MITM or a
legitimate server key rotation; warn loudly */
} vc_tofu_status;
/* ── Structs ──────────────────────────────────────────────────────────────── */
/*
@@ -155,6 +185,13 @@ typedef struct vc_config {
const char* client_name; /* e.g. "VoiceCat-macOS" */
const char* client_version; /* e.g. "0.0.1" */
vc_log_level log_level;
/* M4, optional (added at the end — existing brace-initialized callers default this to
* NULL, no source change needed). Path to the TOFU pin file (see VC_EVENT_SERVER_IDENTITY/
* vc_confirm_server_identity). NULL = a built-in relative default
* ("./voicecat_tofu_pins.txt") so existing tests need no real persistence. A real app
* (e.g. the Windows client) should pass an explicit per-user path, e.g.
* "%AppData%\VoiceCat\tofu_pins.txt". */
const char* tofu_store_path;
} vc_config;
typedef struct vc_stream_desc {
@@ -190,6 +227,49 @@ typedef struct vc_device_list {
size_t count;
} vc_device_list;
/* ── Channel / user / stream snapshots (M4 — for the channel-tree/user-list UI) ───────────
* Pull-based: re-call after VC_EVENT_CHANNEL_LIST / VC_EVENT_USER_JOINED / _LEFT / _UPDATED to
* refresh — there is no push variant; those events just mean "go look". Same ownership
* contract as vc_device/vc_device_list above: core-allocated, caller frees with the matching
* vc_free_*, items' const char* fields are invalid after that call. */
typedef struct vc_channel {
uint32_t id;
uint32_t parent_id; /* 0 = root */
const char* name;
int password_protected; /* bool */
uint32_t max_users; /* 0 = unlimited */
} vc_channel;
typedef struct vc_channel_list {
vc_channel* items;
size_t count;
} vc_channel_list;
typedef struct vc_user {
uint32_t id;
const char* nickname;
int is_guest; /* bool */
uint32_t channel_id;
} vc_user;
typedef struct vc_user_list {
vc_user* items;
size_t count;
} vc_user_list;
/* Per-user stream summary — lighter than vc_audio_config; for the full effective Opus config
* of a specific (user_id, stream_id), use the existing vc_get_stream_audio_config. */
typedef struct vc_stream_summary {
uint32_t stream_id;
vc_stream_kind kind;
const char* label;
} vc_stream_summary;
typedef struct vc_stream_summary_list {
vc_stream_summary* items;
size_t count;
} vc_stream_summary_list;
/* Opaque client handle. */
typedef struct vc_client vc_client;
@@ -208,6 +288,11 @@ VC_API vc_result vc_authenticate_user(vc_client* c, const char* username,
const char* password);
/* ── Channels ─────────────────────────────────────────────────────────────── */
/* Result arrives as VC_EVENT_JOIN_RESULT, not a return value beyond "request queued". `password`
* is forwarded to the server's JoinChannelRequest.password for channels with
* vc_channel.password_protected set; NOTE (M4): no in-tree channel currently has a server-side
* password to check against — channel creation/passwords are a future (M5+) feature, so this
* path is wired but not yet exercisable end-to-end. */
VC_API vc_result vc_join_channel(vc_client* c, uint32_t channel_id,
const char* password /* nullable */);
VC_API vc_result vc_leave_channel(vc_client* c);
@@ -221,6 +306,10 @@ VC_API vc_result vc_set_input_device(vc_client* c, uint32_t stream_id,
/* Send-side: input gate mode + PTT key state, and self mute/deafen. */
VC_API vc_result vc_set_input_mode(vc_client* c, vc_input_mode mode);
/* VAD threshold: normalized RMS 0.01.0; default ~0.025. Takes effect immediately —
* recreates the VAD gate if a MIC stream is already active. No-op when mode != VOICE_ACTIVATION
* (value is remembered and applied if the mode switches back). */
VC_API vc_result vc_set_vad_threshold(vc_client* c, float threshold);
VC_API vc_result vc_set_push_to_talk(vc_client* c, int active /* bool */);
VC_API vc_result vc_set_self_mute(vc_client* c, int mic_muted, int deafened);
@@ -249,6 +338,38 @@ VC_API vc_result vc_send_text(vc_client* c, vc_text_scope scope, uint32_t target
VC_API vc_result vc_list_devices(vc_client* c, vc_device_kind kind, vc_device_list* out);
VC_API void vc_free_device_list(vc_device_list* list);
/* ── Channel / user / stream enumeration (M4; mirrors vc_list_devices above) ─────────────── */
VC_API vc_result vc_list_channels(vc_client* c, vc_channel_list* out);
VC_API void vc_free_channel_list(vc_channel_list* list);
VC_API vc_result vc_list_users(vc_client* c, vc_user_list* out);
VC_API void vc_free_user_list(vc_user_list* list);
/* Streams currently owned by user_id (their mic/screen-audio/aux), per the last snapshot/
* event. VC_ERR_INVALID_ARG if user_id is unknown. */
VC_API vc_result vc_list_user_streams(vc_client* c, uint32_t user_id,
vc_stream_summary_list* out);
VC_API void vc_free_stream_summary_list(vc_stream_summary_list* list);
/* ── TOFU server-identity confirmation (M4) — see VC_EVENT_SERVER_IDENTITY/vc_tofu_status ── */
/* Accept or reject the pending server-identity check for the in-progress connect(). Must be
* called after a VC_EVENT_SERVER_IDENTITY event; the io_thread_ holds the connection open
* (ClientHello/auth deferred) until this is called, up to a generous internal timeout (after
* which it's treated as a reject). accept=0 aborts the connection (emits
* VC_EVENT_DISCONNECTED, result=VC_ERR_CRYPTO) and does NOT update the pin file. accept=1 on
* FIRST_CONNECT/MISMATCH updates the pin file to the new fingerprint and proceeds; accept=1 on
* MATCHED is a no-op confirmation (always safe) and proceeds. VC_ERR_INVALID_ARG if no
* identity confirmation is currently pending. */
VC_API vc_result vc_confirm_server_identity(vc_client* c, int accept /* bool */);
/* The Ed25519 identity fingerprint from ServerHello, hex-formatted for display (e.g. "this
* server also identifies as <hex>"). Purely informational — NOT the value
* vc_confirm_server_identity gates on (see vc_tofu_status's doc comment). Empty string if not
* yet available. Pass out_buf=NULL to query the required buffer size via *out_len first;
* otherwise out_buf must be >= *out_len + 1 bytes (NUL-terminated UTF-8/ASCII hex). */
VC_API vc_result vc_get_server_identity_display(vc_client* c, char* out_buf, size_t buf_cap,
size_t* out_len);
#if defined(__cplusplus)
} /* extern "C" */
#endif

View File

@@ -1,5 +1,6 @@
#include "audio/apm_processor.h"
#include <atomic>
#include <chrono>
#include <cmath>
@@ -41,14 +42,18 @@ class EnergyVadProcessor final : public ApmProcessor {
sum_sq += s * s;
}
double rms = std::sqrt(sum_sq / samples);
if (rms >= threshold_) last_voice_ms_ = steady_now_ms();
if (rms >= threshold_.load(std::memory_order_relaxed)) last_voice_ms_ = steady_now_ms();
}
return (steady_now_ms() - last_voice_ms_) < hang_time_ms_;
}
void set_threshold(float t) override {
threshold_.store(t, std::memory_order_relaxed);
}
private:
float threshold_;
int64_t hang_time_ms_;
std::atomic<float> threshold_;
int64_t hang_time_ms_;
int64_t last_voice_ms_ = 0; // epoch start -> gate begins closed until first loud frame
};

View File

@@ -24,6 +24,10 @@ class ApmProcessor {
// Returns false → caller should skip encode/send (silence gate).
virtual bool process_capture(int16_t* pcm, int samples, int sample_rate) = 0;
// Update the VAD RMS threshold in-place (used by EnergyVadProcessor; no-op in passthrough).
// Safe to call from any thread — EnergyVadProcessor stores it atomically.
virtual void set_threshold(float) {}
// Factory: returns a real APM if VOICECAT_HAS_APM is defined, else a passthrough. Used for
// recv-side per-stream noise reduction (docs/voice.md §10) — gating doesn't apply there, so
// this stays a passthrough until a real APM/NS backend exists (still inert; see

View File

@@ -25,6 +25,7 @@
#include <algorithm>
#include <chrono>
#include <cstring>
#include <filesystem>
#include "protocol/protocol.h"
@@ -41,7 +42,14 @@ std::vector<uint8_t> make_frame(const voicecat::v1::Envelope& env) {
// ── vc_client M1 implementation ───────────────────────────────────────────────
vc_client::vc_client(const vc_config& cfg, vc_callbacks cb) : cfg_(cfg), cb_(cb) {}
vc_client::vc_client(const vc_config& cfg, vc_callbacks cb) : cfg_(cfg), cb_(cb) {
// M4 TOFU: NULL/empty tofu_store_path falls back to a relative default so existing tests
// (which never set this field) keep working without real per-user persistence.
std::filesystem::path tofu_path = (cfg.tofu_store_path && cfg.tofu_store_path[0])
? std::filesystem::path(cfg.tofu_store_path)
: std::filesystem::path("voicecat_tofu_pins.txt");
tofu_store_ = std::make_unique<voicecat::crypto::TofuStore>(std::move(tofu_path));
}
vc_client::~vc_client() { disconnect(); }
@@ -96,6 +104,16 @@ vc_result vc_client::disconnect() {
io_stop_.store(true, std::memory_order_release);
// Unblock a run_io() thread that's currently waiting on vc_confirm_server_identity() —
// without this, disconnecting mid-dialog would strand io_thread_ until the 120s timeout.
{
std::lock_guard lk(tofu_mu_);
if (tofu_decision_pending_) {
tofu_decision_pending_ = false;
tofu_cv_.notify_all();
}
}
// Close the socket to unblock blocking TLS reads/writes.
int fd = io_fd_.load(std::memory_order_acquire);
if (fd != -1) {
@@ -175,6 +193,66 @@ void vc_client::run_io(std::string host, uint16_t port) {
}
}
// ── TOFU server-identity gate (M4) ──────────────────────────────────────
// Pins the TLS leaf cert's own fingerprint (real, verifiable right here from the
// handshake) — NOT the declared Ed25519 server_identity_fingerprint from ServerHello,
// which hasn't even arrived yet at this point (it's sent *inside* this now-established
// tunnel) and isn't cryptographically bound to this cert anyway (docs/security.md
// §1.1). See voicecat.h's vc_tofu_status doc comment.
set_state(VC_STATE_VERIFYING_IDENTITY);
{
std::array<uint8_t, 32> peer_fp{};
vc_tofu_status status = VC_TOFU_MISMATCH;
if (tls_->peer_cert_fingerprint(peer_fp) && tofu_store_) {
auto r = tofu_store_->peek(host, port, peer_fp);
status = (r == voicecat::crypto::TofuResult::FirstConnect) ? VC_TOFU_FIRST_CONNECT
: (r == voicecat::crypto::TofuResult::Matched) ? VC_TOFU_MATCHED
: VC_TOFU_MISMATCH;
}
std::string fp_hex;
{
static const char* hex = "0123456789abcdef";
fp_hex.reserve(peer_fp.size() * 2);
for (auto b : peer_fp) { fp_hex += hex[b >> 4]; fp_hex += hex[b & 0xf]; }
}
{
std::lock_guard<std::mutex> set_lk(tofu_mu_);
tofu_decision_pending_ = true;
tofu_accept_ = false;
}
vc_event ev{};
ev.type = VC_EVENT_SERVER_IDENTITY;
ev.u32a = static_cast<uint32_t>(status);
ev.text = fp_hex.c_str();
emit(ev);
bool accepted;
{
std::unique_lock lk(tofu_mu_);
tofu_cv_.wait_for(lk, std::chrono::seconds(120), [&] {
return !tofu_decision_pending_ || io_stop_.load(std::memory_order_acquire);
});
// Timeout or an external stop (disconnect() during the wait) both leave
// tofu_decision_pending_ true here — treated as a reject, per voicecat.h.
accepted = tofu_decision_pending_ ? false : tofu_accept_;
tofu_decision_pending_ = false;
}
if (!accepted) {
tls_.reset();
emit_disconnected(VC_ERR_CRYPTO, "server identity rejected");
close_sock(sock);
io_fd_.store(-1);
goto cleanup;
}
if (status != VC_TOFU_MATCHED && tofu_store_) {
tofu_store_->pin(host, port, peer_fp);
}
}
// 50 ms timeout so we can drain sends between reads.
tls_->set_read_timeout(50);
@@ -317,6 +395,12 @@ void vc_client::handle_envelope(const voicecat::v1::Envelope& env) {
case voicecat::v1::Envelope::kUserEvent:
handle_user_event(env.user_event());
break;
case voicecat::v1::Envelope::kChannelEvent:
handle_channel_event(env.channel_event());
break;
case voicecat::v1::Envelope::kJoinChannelResult:
handle_join_channel_result(env.join_channel_result());
break;
case voicecat::v1::Envelope::kTextMessage:
handle_text_message(env.text_message());
break;
@@ -339,6 +423,19 @@ void vc_client::handle_envelope(const voicecat::v1::Envelope& env) {
void vc_client::handle_server_hello(const voicecat::v1::ServerHello& msg, uint64_t /*req_id*/) {
server_udp_port_ = static_cast<uint16_t>(msg.udp_port());
// M4: stash the declared Ed25519 fingerprint for vc_get_server_identity_display() —
// display-only, not the TOFU-pinned value (that's the TLS cert fingerprint, gated before
// ClientHello was even sent — see the TOFU block above in run_io()).
{
const std::string& raw = msg.server_identity_fingerprint();
static const char* hex = "0123456789abcdef";
std::string fp_hex;
fp_hex.reserve(raw.size() * 2);
for (unsigned char b : raw) { fp_hex += hex[b >> 4]; fp_hex += hex[b & 0xf]; }
std::lock_guard<std::mutex> lk(tofu_mu_);
pending_identity_fp_hex_ = std::move(fp_hex);
}
// Server acknowledged our ClientHello. Now send AuthRequest (or queue it).
std::optional<PendingAuth> auth;
{
@@ -383,15 +480,42 @@ void vc_client::handle_auth_result(const voicecat::v1::AuthResult& msg) {
}
void vc_client::handle_server_state(const voicecat::v1::ServerStateSnapshot& snap) {
session_model_.apply_snapshot(snap);
{
std::lock_guard<std::mutex> lk(session_model_mu_);
session_model_.apply_snapshot(snap);
}
for (const auto& u : snap.users()) sync_remote_streams(u);
vc_event ev{};
ev.type = VC_EVENT_CHANNEL_LIST;
emit(ev);
}
void vc_client::handle_channel_event(const voicecat::v1::ChannelEvent& ce) {
{
std::lock_guard<std::mutex> lk(session_model_mu_);
session_model_.apply_channel_event(ce);
}
// Same "go look" signal vc_list_channels' callers already poll on after the initial
// snapshot — see voicecat.h's VC_EVENT_CHANNEL_LIST doc comment.
vc_event ev{};
ev.type = VC_EVENT_CHANNEL_LIST;
emit(ev);
}
void vc_client::handle_join_channel_result(const voicecat::v1::JoinChannelResult& msg) {
vc_event ev{};
ev.type = VC_EVENT_JOIN_RESULT;
ev.result = msg.ok() ? VC_OK : VC_ERR_PROTOCOL;
ev.channel_id = msg.channel_id();
if (!msg.ok()) ev.text = msg.error().c_str();
emit(ev);
}
void vc_client::handle_user_event(const voicecat::v1::UserEvent& ue) {
session_model_.apply_user_event(ue);
{
std::lock_guard<std::mutex> lk(session_model_mu_);
session_model_.apply_user_event(ue);
}
vc_event ev{};
const auto& user = ue.user();
@@ -492,11 +616,16 @@ vc_result vc_client::authenticate_user(const char* username, const char* passwor
return VC_OK;
}
vc_result vc_client::join_channel(uint32_t channel_id, const char* /*password*/) {
vc_result vc_client::join_channel(uint32_t channel_id, const char* password) {
if (state_net_.load() != VC_STATE_CONNECTED) return VC_ERR_NOT_CONNECTED;
voicecat::v1::Envelope req;
req.set_request_id(next_req_id_++);
req.mutable_join_channel()->set_channel_id(channel_id);
auto* jc = req.mutable_join_channel();
jc->set_channel_id(channel_id);
// See voicecat.h's vc_join_channel doc comment: wired through to the wire message, but no
// in-tree channel has a server-side password to check yet (no channel-creation feature
// exists — M5+).
if (password) jc->set_password(password);
queue_envelope(req);
return VC_OK;
}
@@ -662,13 +791,15 @@ void vc_client::on_capture_frame(int kind, const int16_t* pcm, int samples) {
// bypass this: gating a screen-share on the user's own voice activity would silently drop
// shared music/video audio whenever the user isn't talking, which defeats the feature.
if (kind == static_cast<int>(VC_STREAM_MIC)) {
if (current_input_mode_.load(std::memory_order_acquire) == VC_INPUT_PUSH_TO_TALK) {
auto mode = current_input_mode_.load(std::memory_order_acquire);
if (mode == VC_INPUT_PUSH_TO_TALK) {
if (!ptt_active_.load(std::memory_order_acquire)) return; // gate closed
} else if (mic_vad_) {
} else if (mode == VC_INPUT_VOICE_ACTIVATION && mic_vad_) {
// EnergyVadProcessor never writes through the pointer (see apm_processor.cpp); the
// const_cast is safe and avoids splitting ApmProcessor's interface just for this.
if (!mic_vad_->process_capture(const_cast<int16_t*>(pcm), samples, 48000)) return;
}
// VC_INPUT_ALWAYS_ON: no gate — fall through and always send.
}
if (!media_send_crypto_) return;
@@ -875,7 +1006,8 @@ void vc_client::handle_stream_announce_result(uint64_t req_id,
// Construct the MIC VAD once, here on io_thread_ (not the RT capture callback) — see
// client.h's comment on mic_vad_.
if (kind == static_cast<int>(VC_STREAM_MIC) && !mic_vad_) {
mic_vad_ = voicecat::audio::ApmProcessor::create_vad();
mic_vad_ = voicecat::audio::ApmProcessor::create_vad(
vad_threshold_.load(std::memory_order_relaxed));
}
}
@@ -961,6 +1093,13 @@ vc_result vc_client::set_input_mode(vc_input_mode mode) {
return VC_OK;
}
vc_result vc_client::set_vad_threshold(float threshold) {
if (threshold < 0.0f || threshold > 1.0f) return VC_ERR_INVALID_ARG;
vad_threshold_.store(threshold, std::memory_order_relaxed);
if (mic_vad_) mic_vad_->set_threshold(threshold);
return VC_OK;
}
vc_result vc_client::set_push_to_talk(bool active) {
ptt_active_.store(active, std::memory_order_release);
return VC_OK;
@@ -982,17 +1121,22 @@ vc_result vc_client::set_self_mute(bool mic_muted, bool deafened) {
vc_result vc_client::set_remote_stream(uint32_t user_id, uint32_t stream_id, float gain,
bool muted, bool noise_reduction) {
if (state_net_.load(std::memory_order_acquire) != VC_STATE_CONNECTED) return VC_ERR_NOT_CONNECTED;
const auto* user = session_model_.find_user(user_id);
if (!user) return VC_ERR_INVALID_ARG;
for (const auto& s : user->streams) {
if (s.stream_id == stream_id) {
audio_engine_.set_stream_gain(s.ssrc, gain);
audio_engine_.set_stream_mute(s.ssrc, muted);
audio_engine_.set_stream_noise_reduction(s.ssrc, noise_reduction);
return VC_OK;
uint32_t ssrc = 0;
bool found = false;
{
std::lock_guard<std::mutex> lk(session_model_mu_);
const auto* user = session_model_.find_user(user_id);
if (user) {
for (const auto& s : user->streams) {
if (s.stream_id == stream_id) { ssrc = s.ssrc; found = true; break; }
}
}
}
return VC_ERR_INVALID_ARG;
if (!found) return VC_ERR_INVALID_ARG;
audio_engine_.set_stream_gain(ssrc, gain);
audio_engine_.set_stream_mute(ssrc, muted);
audio_engine_.set_stream_noise_reduction(ssrc, noise_reduction);
return VC_OK;
}
vc_result vc_client::get_stream_audio_config(uint32_t user_id, uint32_t stream_id,
@@ -1017,6 +1161,7 @@ vc_result vc_client::get_stream_audio_config(uint32_t user_id, uint32_t stream_i
return VC_OK;
}
std::lock_guard<std::mutex> lk(session_model_mu_);
const auto* user = session_model_.find_user(user_id);
if (!user) return VC_ERR_INVALID_ARG;
for (const auto& s : user->streams) {
@@ -1079,6 +1224,88 @@ vc_result vc_client::list_devices(vc_device_kind kind, vc_device_list* out) {
#endif
}
// ── M4: channel/user/stream snapshot getters ─────────────────────────────────
vc_result vc_client::list_channels(vc_channel_list* out) {
std::lock_guard<std::mutex> lk(session_model_mu_);
const auto& channels = session_model_.channels();
auto* items = new vc_channel[channels.size()];
for (size_t i = 0; i < channels.size(); ++i) {
const auto& ch = channels[i];
auto* name = new char[ch.name.size() + 1];
std::memcpy(name, ch.name.c_str(), ch.name.size() + 1);
items[i].id = ch.id;
items[i].parent_id = ch.parent_id;
items[i].name = name;
items[i].password_protected = ch.password_protected ? 1 : 0;
items[i].max_users = ch.max_users;
}
out->items = items;
out->count = channels.size();
return VC_OK;
}
vc_result vc_client::list_users(vc_user_list* out) {
std::lock_guard<std::mutex> lk(session_model_mu_);
const auto& users = session_model_.users();
auto* items = new vc_user[users.size()];
for (size_t i = 0; i < users.size(); ++i) {
const auto& u = users[i];
auto* nick = new char[u.nickname.size() + 1];
std::memcpy(nick, u.nickname.c_str(), u.nickname.size() + 1);
items[i].id = u.id;
items[i].nickname = nick;
items[i].is_guest = u.is_guest ? 1 : 0;
items[i].channel_id = u.channel_id;
}
out->items = items;
out->count = users.size();
return VC_OK;
}
vc_result vc_client::list_user_streams(uint32_t user_id, vc_stream_summary_list* out) {
std::lock_guard<std::mutex> lk(session_model_mu_);
const auto* user = session_model_.find_user(user_id);
if (!user) return VC_ERR_INVALID_ARG;
auto* items = new vc_stream_summary[user->streams.size()];
for (size_t i = 0; i < user->streams.size(); ++i) {
const auto& s = user->streams[i];
auto* label = new char[s.label.size() + 1];
std::memcpy(label, s.label.c_str(), s.label.size() + 1);
items[i].stream_id = s.stream_id;
items[i].kind = static_cast<vc_stream_kind>(s.kind);
items[i].label = label;
}
out->items = items;
out->count = user->streams.size();
return VC_OK;
}
// ── M4: TOFU server-identity gate ─────────────────────────────────────────────
vc_result vc_client::confirm_server_identity(bool accept) {
std::lock_guard<std::mutex> lk(tofu_mu_);
if (!tofu_decision_pending_) return VC_ERR_INVALID_ARG;
tofu_accept_ = accept;
tofu_decision_pending_ = false;
tofu_cv_.notify_all();
return VC_OK;
}
vc_result vc_client::get_server_identity_display(char* out_buf, size_t buf_cap,
size_t* out_len) {
std::string display;
{
std::lock_guard<std::mutex> lk(tofu_mu_);
display = pending_identity_fp_hex_;
}
if (out_len) *out_len = display.size();
if (!out_buf) return VC_OK; // size-query mode
if (buf_cap < display.size() + 1) return VC_ERR_INVALID_ARG;
std::memcpy(out_buf, display.c_str(), display.size() + 1);
return VC_OK;
}
void vc_client::run_talk_timer() {
while (!talk_timer_stop_.load(std::memory_order_acquire)) {
// Remote streams: ask the engine for edge-triggered transitions, map ssrc -> (user,
@@ -1148,6 +1375,7 @@ vc_result vc_client::stream_start(const vc_stream_desc&, uint32_t*) { return VC_
vc_result vc_client::stream_stop(uint32_t) { return VC_ERR_NOT_IMPLEMENTED; }
vc_result vc_client::set_input_device(uint32_t, const char*) { return VC_ERR_NOT_IMPLEMENTED; }
vc_result vc_client::set_input_mode(vc_input_mode) { return VC_ERR_NOT_IMPLEMENTED; }
vc_result vc_client::set_vad_threshold(float) { return VC_ERR_NOT_IMPLEMENTED; }
vc_result vc_client::set_push_to_talk(bool) { return VC_ERR_NOT_IMPLEMENTED; }
vc_result vc_client::set_self_mute(bool, bool) { return VC_ERR_NOT_IMPLEMENTED; }
vc_result vc_client::set_remote_stream(uint32_t, uint32_t, float, bool, bool) {
@@ -1165,5 +1393,25 @@ vc_result vc_client::get_stream_audio_config(uint32_t, uint32_t, vc_audio_config
vc_result vc_client::test_inject_capture(uint32_t, const int16_t*, size_t) {
return VC_ERR_NOT_IMPLEMENTED;
}
vc_result vc_client::list_channels(vc_channel_list* out) {
out->items = nullptr;
out->count = 0;
return VC_ERR_NOT_IMPLEMENTED;
}
vc_result vc_client::list_users(vc_user_list* out) {
out->items = nullptr;
out->count = 0;
return VC_ERR_NOT_IMPLEMENTED;
}
vc_result vc_client::list_user_streams(uint32_t, vc_stream_summary_list* out) {
out->items = nullptr;
out->count = 0;
return VC_ERR_NOT_IMPLEMENTED;
}
vc_result vc_client::confirm_server_identity(bool) { return VC_ERR_NOT_IMPLEMENTED; }
vc_result vc_client::get_server_identity_display(char*, size_t, size_t* out_len) {
if (out_len) *out_len = 0;
return VC_ERR_NOT_IMPLEMENTED;
}
#endif // VOICECAT_HAS_NET

View File

@@ -22,6 +22,7 @@
#include "audio/audio_engine.h"
#include "codec/opus_codec.h"
#include "crypto/crypto.h"
#include "crypto/tofu_store.h"
#include "net/voice_frame.h"
#include "protocol/envelope.h"
#include "protocol/protocol.h"
@@ -49,6 +50,7 @@ struct vc_client {
vc_result stream_stop(uint32_t stream_id);
vc_result set_input_device(uint32_t stream_id, const char* device_id);
vc_result set_input_mode(vc_input_mode mode);
vc_result set_vad_threshold(float threshold);
vc_result set_push_to_talk(bool active);
vc_result set_self_mute(bool mic_muted, bool deafened);
vc_result set_remote_stream(uint32_t user_id, uint32_t stream_id, float gain, bool muted,
@@ -58,6 +60,15 @@ struct vc_client {
vc_result list_devices(vc_device_kind kind, vc_device_list* out);
// M4: channel/user/stream snapshot getters (read session_model_; see voicecat.h).
vc_result list_channels(vc_channel_list* out);
vc_result list_users(vc_user_list* out);
vc_result list_user_streams(uint32_t user_id, vc_stream_summary_list* out);
// M4: TOFU server-identity gate (see voicecat.h's VC_EVENT_SERVER_IDENTITY doc comment).
vc_result confirm_server_identity(bool accept);
vc_result get_server_identity_display(char* out_buf, size_t buf_cap, size_t* out_len);
// M3: effective Opus config for a (user_id, stream_id) — our own pending/active local
// streams, or any peer's broadcast StreamInfo.audio.
vc_result get_stream_audio_config(uint32_t user_id, uint32_t stream_id,
@@ -113,8 +124,19 @@ struct vc_client {
uint64_t server_session_id_{0};
std::atomic<uint64_t> next_req_id_{1};
// Client-side session model
// Client-side session model. Mutated only on io_thread_ (handle_server_state/
// handle_user_event/handle_channel_event), but read from any thread via the M4
// list_channels/list_users/list_user_streams getters — session_model_mu_ guards both.
voicecat::session::SessionModel session_model_;
mutable std::mutex session_model_mu_;
// ── M4: TOFU server-identity gate ───────────────────────────────────────────
std::unique_ptr<voicecat::crypto::TofuStore> tofu_store_; // owns the pin file
std::mutex tofu_mu_;
std::condition_variable tofu_cv_;
bool tofu_decision_pending_{false};
bool tofu_accept_{false};
std::string pending_identity_fp_hex_; // ServerHello's Ed25519 fp, display-only
// ── M2: UDP / media plane ────────────────────────────────────────────────────
std::array<uint8_t, 16> udp_token_{};
@@ -187,6 +209,7 @@ struct vc_client {
// lands (handle_stream_announce_result, on io_thread_ — not the RT capture callback).
std::atomic<vc_input_mode> current_input_mode_{VC_INPUT_VOICE_ACTIVATION};
std::atomic<bool> ptt_active_{false};
std::atomic<float> vad_threshold_{0.025f}; // remembered across mode switches
std::unique_ptr<voicecat::audio::ApmProcessor> mic_vad_;
// teardown_voice() is called both from run_io()'s own cleanup (on the io_thread_, when
@@ -206,6 +229,8 @@ struct vc_client {
void handle_auth_result(const voicecat::v1::AuthResult& msg);
void handle_server_state(const voicecat::v1::ServerStateSnapshot& snap);
void handle_user_event(const voicecat::v1::UserEvent& ue);
void handle_channel_event(const voicecat::v1::ChannelEvent& ce);
void handle_join_channel_result(const voicecat::v1::JoinChannelResult& msg);
void handle_text_message(const voicecat::v1::TextMessage& msg);
void handle_disconnect(const voicecat::v1::Disconnect& msg);
void handle_udp_binding_ack(const voicecat::v1::UdpBinding& msg);

View File

@@ -283,6 +283,14 @@ bool TlsContext::export_keying_material(const char* label, const uint8_t* ctx, s
ctx, ctx_len, ctx != nullptr) == 0;
}
bool TlsContext::peer_cert_fingerprint(std::array<uint8_t, 32>& out) const {
if (!ready_) return false;
const mbedtls_x509_crt* peer = mbedtls_ssl_get_peer_cert(&ssl_);
if (!peer) return false;
mbedtls_sha256(peer->raw.p, peer->raw.len, out.data(), 0);
return true;
}
// ── SodiumMediaCrypto ─────────────────────────────────────────────────────────
SodiumMediaCrypto::SodiumMediaCrypto(

View File

@@ -88,6 +88,13 @@ class TlsContext {
bool export_keying_material(const char* label, const uint8_t* ctx, size_t ctx_len,
uint8_t* out, size_t out_len);
// M4 TOFU: SHA-256 of the peer's leaf X.509 certificate (DER), valid only after a
// successful Role::Client handshake(). This is the value vc_client pins — see
// voicecat.h's vc_tofu_status doc comment for why the cert fingerprint is pinned instead
// of the declared Ed25519 server_identity_fingerprint. Returns false if no peer cert is
// available (e.g. Role::Server, or handshake() hasn't succeeded).
bool peer_cert_fingerprint(std::array<uint8_t, 32>& out) const;
// Whether the handshake completed.
bool ready() const { return ready_; }

View File

@@ -25,6 +25,21 @@ TofuResult TofuStore::check_and_pin(const std::string& host, uint16_t port,
return (it->second == fingerprint) ? TofuResult::Matched : TofuResult::Mismatch;
}
TofuResult TofuStore::peek(const std::string& host, uint16_t port,
const std::array<uint8_t, 32>& fingerprint) const {
std::lock_guard<std::mutex> lk(mu_);
auto it = pins_.find(make_key(host, port));
if (it == pins_.end()) return TofuResult::FirstConnect;
return (it->second == fingerprint) ? TofuResult::Matched : TofuResult::Mismatch;
}
void TofuStore::pin(const std::string& host, uint16_t port,
const std::array<uint8_t, 32>& fingerprint) {
std::lock_guard<std::mutex> lk(mu_);
pins_[make_key(host, port)] = fingerprint;
save();
}
void TofuStore::remove(const std::string& host, uint16_t port) {
std::lock_guard<std::mutex> lk(mu_);
pins_.erase(make_key(host, port));

View File

@@ -29,9 +29,21 @@ class TofuStore {
// Check the fingerprint for host:port. Stores on first connect.
// Thread-safe (single-writer lock).
// NOTE: kept for compatibility; the M4 gated-confirmation flow (vc_client) uses peek()
// + pin() instead, since check_and_pin's unconditional first-connect write is wrong for a
// flow where the application must approve the fingerprint before it's trusted/persisted.
TofuResult check_and_pin(const std::string& host, uint16_t port,
const std::array<uint8_t, 32>& fingerprint);
// Read-only — classifies the fingerprint against any existing pin WITHOUT writing to disk.
// Use this before the application has had a chance to approve a first-connect/mismatch.
TofuResult peek(const std::string& host, uint16_t port,
const std::array<uint8_t, 32>& fingerprint) const;
// Persist the pin for host:port. Call only after the caller has accepted a FIRST_CONNECT
// or MISMATCH classification from peek() — accepting a MATCHED result needs no call here.
void pin(const std::string& host, uint16_t port, const std::array<uint8_t, 32>& fingerprint);
// Remove the pin for host:port (e.g. after user explicitly acknowledges a key change).
void remove(const std::string& host, uint16_t port);
@@ -44,7 +56,7 @@ class TofuStore {
void save() const;
std::filesystem::path path_;
std::mutex mu_;
mutable std::mutex mu_;
std::unordered_map<std::string, std::array<uint8_t, 32>> pins_;
};

View File

@@ -324,11 +324,34 @@ void TcpServerConn::close() {
// ── TcpAcceptor ─────────────────────────────────────────────────────────────
TcpAcceptor::TcpAcceptor(asio::io_context& io, uint16_t port, ConnFactory factory)
: acceptor_(io, asio::ip::tcp::endpoint(asio::ip::tcp::v4(), port)),
factory_(std::move(factory)) {
acceptor_.set_option(asio::ip::tcp::acceptor::reuse_address(true));
namespace {
// Try IPv6 dual-stack first (one socket handles both ::1 and 127.0.0.1 — fixes the common
// Windows case where `localhost` resolves to ::1 before 127.0.0.1). Falls back to IPv4-only
// if the OS has IPv6 disabled or the dual-stack bind fails for any reason.
asio::ip::tcp::acceptor make_acceptor(asio::io_context& io, uint16_t port) {
asio::ip::tcp::acceptor acc(io);
std::error_code ec;
acc.open(asio::ip::tcp::v6(), ec);
if (!ec) {
acc.set_option(asio::ip::v6_only(false), ec); // dual-stack
acc.set_option(asio::ip::tcp::acceptor::reuse_address(true));
acc.bind(asio::ip::tcp::endpoint(asio::ip::tcp::v6(), port), ec);
if (!ec) acc.listen(asio::socket_base::max_listen_connections, ec);
}
if (ec) {
if (acc.is_open()) { std::error_code ignored; acc.close(ignored); }
acc.open(asio::ip::tcp::v4());
acc.set_option(asio::ip::tcp::acceptor::reuse_address(true));
acc.bind(asio::ip::tcp::endpoint(asio::ip::tcp::v4(), port));
acc.listen(asio::socket_base::max_listen_connections);
}
return acc;
}
} // namespace
TcpAcceptor::TcpAcceptor(asio::io_context& io, uint16_t port, ConnFactory factory)
: acceptor_(make_acceptor(io, port)),
factory_(std::move(factory)) {}
void TcpAcceptor::start() { do_accept(); }

View File

@@ -55,8 +55,11 @@ void SessionModel::apply_snapshot(const voicecat::v1::ServerStateSnapshot& snap)
channels_.clear();
for (const auto& pb : snap.channels()) {
Channel ch;
ch.id = pb.id();
ch.name = pb.name();
ch.id = pb.id();
ch.parent_id = pb.parent_id();
ch.name = pb.name();
ch.password_protected = pb.password_protected();
ch.max_users = pb.max_users();
channels_.push_back(std::move(ch));
}
@@ -103,8 +106,11 @@ void SessionModel::apply_channel_event(const voicecat::v1::ChannelEvent& ev) {
if (ev.kind() == Kind::CREATED || ev.kind() == Kind::UPDATED) {
const auto& pb = ev.channel();
Channel ch;
ch.id = pb.id();
ch.name = pb.name();
ch.id = pb.id();
ch.parent_id = pb.parent_id();
ch.name = pb.name();
ch.password_protected = pb.password_protected();
ch.max_users = pb.max_users();
auto it = std::find_if(channels_.begin(), channels_.end(),
[&](const Channel& x) { return x.id == ch.id; });
@@ -112,7 +118,11 @@ void SessionModel::apply_channel_event(const voicecat::v1::ChannelEvent& ev) {
else channels_.push_back(std::move(ch));
} else if (ev.kind() == Kind::DELETED) {
uint32_t cid = ev.channel().id();
// deleted_id, not channel().id() — the proto leaves `channel` unset for deletes
// (docs/protocol.md, core/proto/voicecat.proto's ChannelEvent). Pre-existing bug, dead
// code until something actually emits ChannelEvent (no channel CRUD exists yet — M5+),
// fixed here while touching this function for the M4 field-population fix.
uint32_t cid = ev.deleted_id();
channels_.erase(std::remove_if(channels_.begin(), channels_.end(),
[cid](const Channel& x) { return x.id == cid; }),
channels_.end());

View File

@@ -102,6 +102,11 @@ vc_result vc_set_input_mode(vc_client* c, vc_input_mode mode) {
return c->set_input_mode(mode);
}
vc_result vc_set_vad_threshold(vc_client* c, float threshold) {
if (c == nullptr) return VC_ERR_INVALID_ARG;
return c->set_vad_threshold(threshold);
}
vc_result vc_set_push_to_talk(vc_client* c, int active) {
if (c == nullptr) return VC_ERR_INVALID_ARG;
return c->set_push_to_talk(active != 0);
@@ -152,4 +157,54 @@ void vc_free_device_list(vc_device_list* list) {
list->count = 0;
}
vc_result vc_list_channels(vc_client* c, vc_channel_list* out) {
if (c == nullptr || out == nullptr) return VC_ERR_INVALID_ARG;
return c->list_channels(out);
}
void vc_free_channel_list(vc_channel_list* list) {
if (list == nullptr || list->items == nullptr) return;
for (size_t i = 0; i < list->count; ++i) delete[] list->items[i].name;
delete[] list->items;
list->items = nullptr;
list->count = 0;
}
vc_result vc_list_users(vc_client* c, vc_user_list* out) {
if (c == nullptr || out == nullptr) return VC_ERR_INVALID_ARG;
return c->list_users(out);
}
void vc_free_user_list(vc_user_list* list) {
if (list == nullptr || list->items == nullptr) return;
for (size_t i = 0; i < list->count; ++i) delete[] list->items[i].nickname;
delete[] list->items;
list->items = nullptr;
list->count = 0;
}
vc_result vc_list_user_streams(vc_client* c, uint32_t user_id, vc_stream_summary_list* out) {
if (c == nullptr || out == nullptr) return VC_ERR_INVALID_ARG;
return c->list_user_streams(user_id, out);
}
void vc_free_stream_summary_list(vc_stream_summary_list* list) {
if (list == nullptr || list->items == nullptr) return;
for (size_t i = 0; i < list->count; ++i) delete[] list->items[i].label;
delete[] list->items;
list->items = nullptr;
list->count = 0;
}
vc_result vc_confirm_server_identity(vc_client* c, int accept) {
if (c == nullptr) return VC_ERR_INVALID_ARG;
return c->confirm_server_identity(accept != 0);
}
vc_result vc_get_server_identity_display(vc_client* c, char* out_buf, size_t buf_cap,
size_t* out_len) {
if (c == nullptr) return VC_ERR_INVALID_ARG;
return c->get_server_identity_display(out_buf, buf_cap, out_len);
}
} // extern "C"