feat(M1): TCP/TLS control plane -- auth, channels, ephemeral text

Implements the full M1 milestone. Two clients authenticate over TLS 1.3
(guest + Argon2id password) and exchange channel + private text messages
through a real server. All five ctest --preset m1-dev tests pass in ~1 s.

Key components added:
- vcpkg baseline + m1-dev preset (protobuf/mbedTLS/libsodium/asio/sqlite3)
- FrameCodec feed+emit, encode/decode_envelope, protobuf codegen
- TcpServerConn with blocking TLS handshake thread + tls_read_loop
- TlsContext (mbedTLS 1.3, ECDSA-P256 self-signed cert, TOFU on client)
- WorkerPool (3 threads, used for Argon2id)
- Database: SQLite + libsodium Argon2id, account lifecycle, bootstrap admin
- ServerIdentityManager: Ed25519 key + cert generate/persist/fingerprint
- ConnSession state machine: WaitingHello -> WaitingAuth -> Authenticated
- SessionRegistry: channel tree, user map, text routing, broadcast
- vc_client full M1 C ABI: connect/TLS/handshake/auth/text/disconnect
- voicecat-admin CLI: account add/reset/del/list
- test_m1_integration: M1 exit criterion, verified green

Bug fixed: double-framing in ConnSession::send_envelope -- encode_envelope
was adding the [4-byte len] prefix, then TcpServerConn::send_frame added
a second one, causing the client to parse [len][proto] as protobuf (silent
failure). Fixed by serializing raw protobuf bytes in send_envelope and
letting send_frame apply the single length prefix.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-06-15 23:48:44 +02:00
parent b332b0972b
commit 63f457fc54
42 changed files with 4180 additions and 190 deletions

View File

@@ -1,8 +1,288 @@
#include "crypto/crypto.h"
#ifdef VOICECAT_HAS_NET
#include <cstring>
#include <fstream>
#include <sstream>
#include <stdexcept>
#include <mbedtls/error.h>
#include <mbedtls/sha256.h>
#include <mbedtls/ssl.h>
#include <mbedtls/x509_crt.h>
namespace voicecat::crypto {
// M0 stub. Brought up in M1 (TLS 1.3 via mbedTLS) and M2 (media AEAD via libsodium).
// See docs/security.md §12.
// ── Helpers ───────────────────────────────────────────────────────────────────
static void throw_if(int rc, const char* msg) {
if (rc != 0) {
char buf[256];
mbedtls_strerror(rc, buf, sizeof(buf));
throw std::runtime_error(std::string(msg) + ": " + buf);
}
}
static std::string compute_hex_fingerprint(const uint8_t* data, size_t len) {
uint8_t hash[32];
mbedtls_sha256(data, len, hash, 0);
std::string s;
s.reserve(64);
const char* hex = "0123456789abcdef";
for (auto b : hash) {
s += hex[b >> 4];
s += hex[b & 0xf];
}
return s;
}
// ── ServerIdentity ────────────────────────────────────────────────────────────
ServerIdentity ServerIdentity::generate() {
ServerIdentity id;
crypto_sign_ed25519_keypair(id.pk.data(), id.sk.data());
// Fingerprint = SHA-256 of the public key
mbedtls_sha256(id.pk.data(), id.pk.size(), id.fingerprint.data(), 0);
return id;
}
ServerIdentity ServerIdentity::load(const std::filesystem::path& path) {
std::ifstream f(path, std::ios::binary);
if (!f) throw std::runtime_error("Cannot open identity file: " + path.string());
ServerIdentity id;
f.read(reinterpret_cast<char*>(id.pk.data()), id.pk.size());
f.read(reinterpret_cast<char*>(id.sk.data()), id.sk.size());
if (!f) throw std::runtime_error("Identity file truncated: " + path.string());
mbedtls_sha256(id.pk.data(), id.pk.size(), id.fingerprint.data(), 0);
return id;
}
void ServerIdentity::save(const std::filesystem::path& path) const {
std::ofstream f(path, std::ios::binary | std::ios::trunc);
if (!f) throw std::runtime_error("Cannot write identity file: " + path.string());
f.write(reinterpret_cast<const char*>(pk.data()), pk.size());
f.write(reinterpret_cast<const char*>(sk.data()), sk.size());
}
std::string ServerIdentity::fingerprint_hex() const {
std::string s;
s.reserve(96);
const char* hex = "0123456789ABCDEF";
for (size_t i = 0; i < fingerprint.size(); ++i) {
if (i > 0) s += ':';
s += hex[fingerprint[i] >> 4];
s += hex[fingerprint[i] & 0xf];
}
return s;
}
// ── ServerCert ────────────────────────────────────────────────────────────────
ServerCert ServerCert::generate(const std::string& server_name) {
mbedtls_entropy_context entropy;
mbedtls_ctr_drbg_context ctr_drbg;
mbedtls_pk_context key;
mbedtls_x509write_cert cert;
mbedtls_entropy_init(&entropy);
mbedtls_ctr_drbg_init(&ctr_drbg);
mbedtls_pk_init(&key);
mbedtls_x509write_crt_init(&cert);
try {
const char* pers = "voicecat_cert_gen";
throw_if(mbedtls_ctr_drbg_seed(&ctr_drbg, mbedtls_entropy_func, &entropy,
reinterpret_cast<const unsigned char*>(pers),
strlen(pers)),
"ctr_drbg_seed");
// Generate ECDSA-P256 key
throw_if(mbedtls_pk_setup(&key, mbedtls_pk_info_from_type(MBEDTLS_PK_ECKEY)),
"pk_setup");
throw_if(mbedtls_ecp_gen_key(MBEDTLS_ECP_DP_SECP256R1, mbedtls_pk_ec(key),
mbedtls_ctr_drbg_random, &ctr_drbg),
"ecp_gen_key");
// Build self-signed cert
mbedtls_x509write_crt_set_version(&cert, MBEDTLS_X509_CRT_VERSION_3);
mbedtls_x509write_crt_set_md_alg(&cert, MBEDTLS_MD_SHA256);
mbedtls_x509write_crt_set_subject_key(&cert, &key);
mbedtls_x509write_crt_set_issuer_key(&cert, &key);
std::string dn = "CN=" + (server_name.empty() ? std::string("voicecat") : server_name);
throw_if(mbedtls_x509write_crt_set_subject_name(&cert, dn.c_str()), "set_subject");
throw_if(mbedtls_x509write_crt_set_issuer_name(&cert, dn.c_str()), "set_issuer");
// Serial = 0x01 (1 byte, value 1)
uint8_t serial_raw[] = {0x01};
throw_if(mbedtls_x509write_crt_set_serial_raw(&cert, serial_raw, sizeof(serial_raw)),
"set_serial");
// Valid for 10 years
throw_if(mbedtls_x509write_crt_set_validity(&cert, "20240101000000",
"20340101000000"),
"set_validity");
throw_if(mbedtls_x509write_crt_set_basic_constraints(&cert, 0, -1),
"set_basic_constraints");
// Write PEM cert
unsigned char cert_buf[4096] = {};
throw_if(mbedtls_x509write_crt_pem(&cert, cert_buf, sizeof(cert_buf),
mbedtls_ctr_drbg_random, &ctr_drbg),
"write_cert_pem");
// Write PEM key
unsigned char key_buf[4096] = {};
throw_if(mbedtls_pk_write_key_pem(&key, key_buf, sizeof(key_buf)), "write_key_pem");
ServerCert result;
result.pem_cert = reinterpret_cast<char*>(cert_buf);
result.pem_key = reinterpret_cast<char*>(key_buf);
mbedtls_x509write_crt_free(&cert);
mbedtls_pk_free(&key);
mbedtls_ctr_drbg_free(&ctr_drbg);
mbedtls_entropy_free(&entropy);
return result;
} catch (...) {
mbedtls_x509write_crt_free(&cert);
mbedtls_pk_free(&key);
mbedtls_ctr_drbg_free(&ctr_drbg);
mbedtls_entropy_free(&entropy);
throw;
}
}
ServerCert ServerCert::load(const std::filesystem::path& cert_path,
const std::filesystem::path& key_path) {
auto read_file = [](const std::filesystem::path& p) -> std::string {
std::ifstream f(p);
if (!f) throw std::runtime_error("Cannot open: " + p.string());
return {std::istreambuf_iterator<char>(f), {}};
};
ServerCert c;
c.pem_cert = read_file(cert_path);
c.pem_key = read_file(key_path);
return c;
}
void ServerCert::save(const std::filesystem::path& cert_path,
const std::filesystem::path& key_path) const {
auto write_file = [](const std::filesystem::path& p, const std::string& s) {
std::ofstream f(p, std::ios::trunc);
if (!f) throw std::runtime_error("Cannot write: " + p.string());
f << s;
};
write_file(cert_path, pem_cert);
write_file(key_path, pem_key);
}
// ── TlsContext ────────────────────────────────────────────────────────────────
TlsContext::TlsContext(Role role, const ServerCert* server_cert,
const std::array<uint8_t, 32>* pinned_fp)
: role_(role), pinned_fp_(pinned_fp) {
mbedtls_entropy_init(&entropy_);
mbedtls_ctr_drbg_init(&ctr_drbg_);
mbedtls_ssl_init(&ssl_);
mbedtls_ssl_config_init(&conf_);
mbedtls_x509_crt_init(&srvcert_);
mbedtls_pk_init(&pkey_);
const char* pers = (role == Role::Server) ? "vc_server_tls" : "vc_client_tls";
throw_if(mbedtls_ctr_drbg_seed(&ctr_drbg_, mbedtls_entropy_func, &entropy_,
reinterpret_cast<const unsigned char*>(pers),
strlen(pers)),
"ctr_drbg_seed");
int endpoint = (role == Role::Server) ? MBEDTLS_SSL_IS_SERVER : MBEDTLS_SSL_IS_CLIENT;
throw_if(mbedtls_ssl_config_defaults(&conf_, endpoint, MBEDTLS_SSL_TRANSPORT_STREAM,
MBEDTLS_SSL_PRESET_DEFAULT),
"ssl_config_defaults");
// TLS 1.3 only
mbedtls_ssl_conf_min_tls_version(&conf_, MBEDTLS_SSL_VERSION_TLS1_3);
mbedtls_ssl_conf_max_tls_version(&conf_, MBEDTLS_SSL_VERSION_TLS1_3);
mbedtls_ssl_conf_rng(&conf_, mbedtls_ctr_drbg_random, &ctr_drbg_);
if (role == Role::Server && server_cert) {
// Parse server cert + key
throw_if(mbedtls_x509_crt_parse(
&srvcert_,
reinterpret_cast<const unsigned char*>(server_cert->pem_cert.c_str()),
server_cert->pem_cert.size() + 1),
"x509_crt_parse");
throw_if(mbedtls_pk_parse_key(
&pkey_,
reinterpret_cast<const unsigned char*>(server_cert->pem_key.c_str()),
server_cert->pem_key.size() + 1,
nullptr, 0, mbedtls_ctr_drbg_random, &ctr_drbg_),
"pk_parse_key");
throw_if(mbedtls_ssl_conf_own_cert(&conf_, &srvcert_, &pkey_), "conf_own_cert");
}
if (role == Role::Client) {
// Skip CA chain verification — we use TOFU via the server identity fingerprint.
mbedtls_ssl_conf_authmode(&conf_, MBEDTLS_SSL_VERIFY_NONE);
}
throw_if(mbedtls_ssl_setup(&ssl_, &conf_), "ssl_setup");
}
TlsContext::~TlsContext() {
mbedtls_ssl_close_notify(&ssl_);
mbedtls_pk_free(&pkey_);
mbedtls_x509_crt_free(&srvcert_);
mbedtls_ssl_free(&ssl_);
mbedtls_ssl_config_free(&conf_);
mbedtls_ctr_drbg_free(&ctr_drbg_);
mbedtls_entropy_free(&entropy_);
}
void TlsContext::set_read_timeout(uint32_t ms) {
mbedtls_ssl_conf_read_timeout(&conf_, ms);
}
bool TlsContext::is_timeout_error(int rc) {
return rc == MBEDTLS_ERR_SSL_TIMEOUT;
}
bool TlsContext::handshake(int socket_fd, std::string& error) {
net_ctx_.fd = socket_fd;
// Use the timeout-capable recv callback so set_read_timeout() takes effect.
mbedtls_ssl_set_bio(&ssl_, &net_ctx_, mbedtls_net_send, mbedtls_net_recv,
mbedtls_net_recv_timeout);
int rc;
while ((rc = mbedtls_ssl_handshake(&ssl_)) != 0) {
if (rc != MBEDTLS_ERR_SSL_WANT_READ && rc != MBEDTLS_ERR_SSL_WANT_WRITE) {
char buf[256];
mbedtls_strerror(rc, buf, sizeof(buf));
error = buf;
return false;
}
}
ready_ = true;
return true;
}
int TlsContext::read(uint8_t* buf, size_t len) {
return mbedtls_ssl_read(&ssl_, buf, len);
}
int TlsContext::write(const uint8_t* buf, size_t len) {
return mbedtls_ssl_write(&ssl_, buf, len);
}
bool TlsContext::export_keying_material(const char* label, const uint8_t* ctx, size_t ctx_len,
uint8_t* out, size_t out_len) {
return mbedtls_ssl_export_keying_material(
&ssl_, out, out_len, label, strlen(label),
ctx, ctx_len, ctx != nullptr) == 0;
}
} // namespace voicecat::crypto
#endif // VOICECAT_HAS_NET

View File

@@ -4,31 +4,121 @@
* Design: docs/security.md. Control channel = TLS 1.3. Media = keys exported from the TLS
* session (RFC 5705 / 8446) + per-frame ChaCha20-Poly1305 with a counter nonce and a
* sliding-window replay filter. Encryption is MANDATORY — never add a plaintext path.
*
* STATUS: M0 stub.
*/
#ifndef VOICECAT_CRYPTO_CRYPTO_H
#define VOICECAT_CRYPTO_CRYPTO_H
#include <cstddef>
#include <cstdint>
#include <string>
#ifdef VOICECAT_HAS_NET
#include <array>
#include <filesystem>
#include <functional>
#include <memory>
// libsodium
#include <sodium.h>
// mbedTLS
#include <mbedtls/ctr_drbg.h>
#include <mbedtls/entropy.h>
#include <mbedtls/net_sockets.h>
#include <mbedtls/pk.h>
#include <mbedtls/ssl.h>
#include <mbedtls/x509_crt.h>
namespace voicecat::crypto {
// TLS 1.3 endpoint wrapper (mbedTLS). Provides the keying-material exporter that seeds
// MediaCrypto, so the UDP path inherits the authenticated control session's trust.
class TlsContext {
public:
// TODO(M1): client/server handshake; read/write; export_keying_material(label,...).
// ── Server identity ────────────────────────────────────────────────────────────
// Long-lived Ed25519 key identifying this server instance across cert rotations.
// Fingerprint is the 32-byte SHA-256 of the public key.
struct ServerIdentity {
std::array<uint8_t, crypto_sign_ed25519_PUBLICKEYBYTES> pk{};
std::array<uint8_t, crypto_sign_ed25519_SECRETKEYBYTES> sk{};
std::array<uint8_t, 32> fingerprint{};
static ServerIdentity generate();
static ServerIdentity load(const std::filesystem::path& path);
void save(const std::filesystem::path& path) const;
std::string fingerprint_hex() const;
};
// Per-frame media encryption. Abstracted so the backend (exported-key AEAD now; a DTLS 1.3
// backend later, if a permissive impl matures) is swappable without touching voice code.
// ── Server TLS certificate ─────────────────────────────────────────────────────
// Self-signed ECDSA-P256 cert for TLS. On first run, generated and persisted.
struct ServerCert {
std::string pem_cert;
std::string pem_key;
static ServerCert generate(const std::string& server_name);
static ServerCert load(const std::filesystem::path& cert_path,
const std::filesystem::path& key_path);
void save(const std::filesystem::path& cert_path,
const std::filesystem::path& key_path) const;
};
// ── TLS 1.3 context ───────────────────────────────────────────────────────────
// Wraps mbedTLS for one TLS connection (server or client side).
// All public methods except close() must be called from a single thread at a time.
class TlsContext {
public:
enum class Role { Server, Client };
// server_cert: required for server role; nullptr for client
// pinned_fp: 32-byte Ed25519 fingerprint to accept (client TOFU); nullptr = any
TlsContext(Role role, const ServerCert* server_cert,
const std::array<uint8_t, 32>* pinned_fp = nullptr);
~TlsContext();
TlsContext(const TlsContext&) = delete;
TlsContext& operator=(const TlsContext&) = delete;
// Perform the TLS handshake over an already-connected BSD socket fd.
// Blocking — run from a WorkerPool thread.
// Returns true on success; error contains a diagnostic string on failure.
bool handshake(int socket_fd, std::string& error);
// Read/write post-handshake (single-threaded). Returns bytes transferred, or <0 on error.
int read(uint8_t* buf, size_t len);
int write(const uint8_t* buf, size_t len);
// RFC 5705 / RFC 8446 §7.5 exporter — derive media keys after handshake.
bool export_keying_material(const char* label, const uint8_t* ctx, size_t ctx_len,
uint8_t* out, size_t out_len);
// Whether the handshake completed.
bool ready() const { return ready_; }
// Underlying socket fd (valid after handshake). For select() in the caller.
int native_fd() const { return net_ctx_.fd; }
// Set per-read timeout (ms, 0 = blocking). Affects post-handshake reads.
void set_read_timeout(uint32_t ms);
// True when the given return value from read() indicates a read timeout.
static bool is_timeout_error(int rc);
private:
Role role_;
const std::array<uint8_t, 32>* pinned_fp_;
bool ready_{false};
mbedtls_entropy_context entropy_{};
mbedtls_ctr_drbg_context ctr_drbg_{};
mbedtls_ssl_context ssl_{};
mbedtls_ssl_config conf_{};
mbedtls_x509_crt srvcert_{};
mbedtls_pk_context pkey_{};
mbedtls_net_context net_ctx_{};
};
// ── Media AEAD (M2) ───────────────────────────────────────────────────────────
// Per-frame voice encryption. Abstracted so the backend is swappable.
class MediaCrypto {
public:
virtual ~MediaCrypto() = default;
// seal/open one voice frame; `aad` carries the routable header fields (e.g. ssrc).
// Returns bytes written, or -1 on failure (replay/auth). TODO(M2).
virtual long seal(const uint8_t* plain, size_t len, const uint8_t* aad, size_t aad_len,
uint8_t* out, size_t out_cap) = 0;
virtual long open(const uint8_t* sealed, size_t len, const uint8_t* aad, size_t aad_len,
@@ -37,4 +127,18 @@ class MediaCrypto {
} // namespace voicecat::crypto
#else // !VOICECAT_HAS_NET — skeleton stubs
namespace voicecat::crypto {
class MediaCrypto {
public:
virtual ~MediaCrypto() = default;
virtual long seal(const uint8_t*, size_t, const uint8_t*, size_t, uint8_t*, size_t) = 0;
virtual long open(const uint8_t*, size_t, const uint8_t*, size_t, uint8_t*, size_t) = 0;
};
} // namespace voicecat::crypto
#endif // VOICECAT_HAS_NET
#endif // VOICECAT_CRYPTO_CRYPTO_H

View File

@@ -0,0 +1,82 @@
#include "crypto/tofu_store.h"
#ifdef VOICECAT_HAS_NET
#include <fstream>
#include <sstream>
#include <stdexcept>
namespace voicecat::crypto {
TofuStore::TofuStore(std::filesystem::path path) : path_(std::move(path)) {
load();
}
TofuResult TofuStore::check_and_pin(const std::string& host, uint16_t port,
const std::array<uint8_t, 32>& fingerprint) {
std::lock_guard<std::mutex> lk(mu_);
auto key = make_key(host, port);
auto it = pins_.find(key);
if (it == pins_.end()) {
pins_[key] = fingerprint;
save();
return TofuResult::FirstConnect;
}
return (it->second == fingerprint) ? TofuResult::Matched : TofuResult::Mismatch;
}
void TofuStore::remove(const std::string& host, uint16_t port) {
std::lock_guard<std::mutex> lk(mu_);
pins_.erase(make_key(host, port));
save();
}
std::string TofuStore::make_key(const std::string& host, uint16_t port) {
return host + ":" + std::to_string(port);
}
std::string TofuStore::fp_to_hex(const std::array<uint8_t, 32>& fp) {
const char* hex = "0123456789abcdef";
std::string s;
s.reserve(64);
for (auto b : fp) { s += hex[b >> 4]; s += hex[b & 0xf]; }
return s;
}
std::array<uint8_t, 32> TofuStore::hex_to_fp(const std::string& hex) {
std::array<uint8_t, 32> fp{};
if (hex.size() != 64) return fp;
auto h2n = [](char c) -> uint8_t {
if (c >= '0' && c <= '9') return c - '0';
if (c >= 'a' && c <= 'f') return c - 'a' + 10;
if (c >= 'A' && c <= 'F') return c - 'A' + 10;
return 0;
};
for (size_t i = 0; i < 32; ++i)
fp[i] = static_cast<uint8_t>((h2n(hex[2*i]) << 4) | h2n(hex[2*i+1]));
return fp;
}
void TofuStore::load() {
std::ifstream f(path_);
if (!f) return;
std::string line;
while (std::getline(f, line)) {
if (line.empty() || line[0] == '#') continue;
std::istringstream ss(line);
std::string key, hex;
if (ss >> key >> hex && hex.size() == 64)
pins_[key] = hex_to_fp(hex);
}
}
void TofuStore::save() const {
std::ofstream f(path_, std::ios::trunc);
if (!f) throw std::runtime_error("Cannot write TOFU store: " + path_.string());
for (auto& [key, fp] : pins_)
f << key << " " << fp_to_hex(fp) << "\n";
}
} // namespace voicecat::crypto
#endif // VOICECAT_HAS_NET

View File

@@ -0,0 +1,54 @@
/*
* crypto/tofu_store.h — Trust-On-First-Use pin storage.
*
* File format: one "host:port <hex-fingerprint>\n" line per entry.
* Used by clients to remember server fingerprints across reconnects.
*/
#ifndef VOICECAT_CRYPTO_TOFU_STORE_H
#define VOICECAT_CRYPTO_TOFU_STORE_H
#ifdef VOICECAT_HAS_NET
#include <array>
#include <filesystem>
#include <mutex>
#include <string>
#include <unordered_map>
namespace voicecat::crypto {
enum class TofuResult {
FirstConnect, // no pin on file; pin has been stored
Matched, // pin matches stored value
Mismatch, // stored pin does not match — possible MITM or server key rotation
};
class TofuStore {
public:
explicit TofuStore(std::filesystem::path path);
// Check the fingerprint for host:port. Stores on first connect.
// Thread-safe (single-writer lock).
TofuResult check_and_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);
private:
static std::string make_key(const std::string& host, uint16_t port);
static std::string fp_to_hex(const std::array<uint8_t, 32>& fp);
static std::array<uint8_t, 32> hex_to_fp(const std::string& hex);
void load();
void save() const;
std::filesystem::path path_;
std::mutex mu_;
std::unordered_map<std::string, std::array<uint8_t, 32>> pins_;
};
} // namespace voicecat::crypto
#endif // VOICECAT_HAS_NET
#endif // VOICECAT_CRYPTO_TOFU_STORE_H