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

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