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

@@ -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"