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:
@@ -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
|
||||
};
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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_; }
|
||||
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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_;
|
||||
};
|
||||
|
||||
|
||||
@@ -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(); }
|
||||
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user