feat(M2): UDP voice/media plane -- SFU relay, Opus, AEAD, jitter buffer

Adds the full voice pipeline: 14-byte binary frame header, ChaCha20-Poly1305
AEAD keyed from the TLS exporter, libopus encode/decode with FEC/PLC/DTX,
an adaptive per-ssrc jitter buffer, a miniaudio capture/playback engine, an
APM passthrough stub, and the UdpBinding/StreamAnnounce signaling chain
wired through ConnSession/SessionRegistry into a new server-side SFU
(MediaRelay) that decrypts and re-encrypts frames per channel member.

Exit criterion verified: test_m2_voice — two headless clients relay 50
encrypted Opus frames through the server; ctest --preset m1-dev is 9/9
green. Also corrects protocol.md's UdpBinding diagram, which described the
UDP-side binding packet as AEAD-sealed when it is in fact a plaintext
bootstrap frame (separate from the TCP/TLS UdpBinding ack).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-06-16 01:31:14 +02:00
parent 63f457fc54
commit 694494a5be
29 changed files with 2548 additions and 86 deletions

View File

@@ -283,6 +283,106 @@ bool TlsContext::export_keying_material(const char* label, const uint8_t* ctx, s
ctx, ctx_len, ctx != nullptr) == 0;
}
// ── SodiumMediaCrypto ─────────────────────────────────────────────────────────
SodiumMediaCrypto::SodiumMediaCrypto(
const uint8_t key[crypto_aead_chacha20poly1305_ietf_KEYBYTES]) {
std::memcpy(key_.data(), key, key_.size());
}
std::unique_ptr<SodiumMediaCrypto> SodiumMediaCrypto::derive(TlsContext& tls, uint8_t ctx_byte) {
uint8_t key[crypto_aead_chacha20poly1305_ietf_KEYBYTES]{};
if (!tls.export_keying_material("voicecat media v1", &ctx_byte, 1, key, sizeof(key)))
return nullptr;
auto p = std::make_unique<SodiumMediaCrypto>(key);
sodium_memzero(key, sizeof(key));
return p;
}
std::unique_ptr<SodiumMediaCrypto> SodiumMediaCrypto::derive_send(TlsContext& tls,
bool is_client) {
return derive(tls, is_client ? 0x00 : 0x01);
}
std::unique_ptr<SodiumMediaCrypto> SodiumMediaCrypto::derive_recv(TlsContext& tls,
bool is_client) {
return derive(tls, is_client ? 0x01 : 0x00);
}
void SodiumMediaCrypto::build_nonce(uint64_t counter, uint8_t nonce[12]) const {
// nonce[0..3] = 0x00 (reserved / zero-padded)
// nonce[4..11] = counter (big-endian u64)
nonce[0] = nonce[1] = nonce[2] = nonce[3] = 0;
nonce[4] = static_cast<uint8_t>(counter >> 56);
nonce[5] = static_cast<uint8_t>(counter >> 48);
nonce[6] = static_cast<uint8_t>(counter >> 40);
nonce[7] = static_cast<uint8_t>(counter >> 32);
nonce[8] = static_cast<uint8_t>(counter >> 24);
nonce[9] = static_cast<uint8_t>(counter >> 16);
nonce[10] = static_cast<uint8_t>(counter >> 8);
nonce[11] = static_cast<uint8_t>(counter & 0xFF);
}
long SodiumMediaCrypto::seal(const uint8_t* plain, size_t len, const uint8_t* aad,
size_t aad_len, uint8_t* out, size_t out_cap) {
if (out_cap < len + crypto_aead_chacha20poly1305_ietf_ABYTES) return -1;
uint8_t nonce[crypto_aead_chacha20poly1305_ietf_NPUBBYTES];
build_nonce(send_counter_++, nonce);
unsigned long long sealed_len = 0;
if (crypto_aead_chacha20poly1305_ietf_encrypt(
out, &sealed_len, plain, static_cast<unsigned long long>(len),
aad, static_cast<unsigned long long>(aad_len),
nullptr, nonce, key_.data()) != 0)
return -1;
return static_cast<long>(sealed_len);
}
long SodiumMediaCrypto::open(const uint8_t* sealed, size_t len, const uint8_t* aad,
size_t aad_len, uint8_t* out, size_t out_cap) {
if (len < crypto_aead_chacha20poly1305_ietf_ABYTES) return -1;
if (out_cap < len - crypto_aead_chacha20poly1305_ietf_ABYTES) return -1;
// Reconstruct 64-bit counter from aad[8..9] (seq, big-endian u16).
// For M2, we zero-extend the 16-bit seq; TODO: add ROC for long sessions.
if (aad_len < 10) return -1;
uint64_t counter = (static_cast<uint64_t>(aad[8]) << 8) | aad[9];
// ── Anti-replay check ────────────────────────────────────────────────────
if (!recv_initialized_) {
recv_highest_ = counter;
recv_window_ = 1; // bit0 = highest itself
recv_initialized_ = true;
} else {
if (counter > recv_highest_) {
uint64_t shift = counter - recv_highest_;
recv_window_ = (shift >= 64) ? 0 : (recv_window_ << shift);
recv_highest_ = counter;
}
uint64_t offset = recv_highest_ - counter;
if (offset >= 64) return -1; // too old
if (recv_window_ & (UINT64_C(1) << offset)) return -1; // replay
}
uint8_t nonce[crypto_aead_chacha20poly1305_ietf_NPUBBYTES];
build_nonce(counter, nonce);
unsigned long long plain_len = 0;
if (crypto_aead_chacha20poly1305_ietf_decrypt(
out, &plain_len, nullptr, sealed, static_cast<unsigned long long>(len),
aad, static_cast<unsigned long long>(aad_len),
nonce, key_.data()) != 0)
return -1;
// Mark this counter as accepted in the window.
uint64_t offset = recv_highest_ - counter;
recv_window_ |= (UINT64_C(1) << offset);
return static_cast<long>(plain_len);
}
} // namespace voicecat::crypto
#endif // VOICECAT_HAS_NET

View File

@@ -119,12 +119,62 @@ class TlsContext {
class MediaCrypto {
public:
virtual ~MediaCrypto() = default;
// Encrypt plain[0..len) with AAD aad[0..aad_len). Write ciphertext+MAC to out.
// out_cap must be >= len + crypto_aead_chacha20poly1305_ietf_ABYTES (16).
// Returns total bytes written on success, or -1 on error.
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;
// Decrypt+authenticate sealed[0..len). len includes the 16-byte MAC.
// Returns number of plaintext bytes written to out, or -1 on auth failure / replay.
virtual long open(const uint8_t* sealed, size_t len, const uint8_t* aad, size_t aad_len,
uint8_t* out, size_t out_cap) = 0;
};
// ── ChaCha20-Poly1305 backend ──────────────────────────────────────────────────
// Keys are derived from the TLS session via RFC 5705 / mbedTLS exporter.
// Nonce scheme: 4 zero bytes ‖ monotonic-counter(u64 big-endian, 8 bytes).
// Anti-replay: 64-bit sliding window keyed on the received counter.
class SodiumMediaCrypto final : public MediaCrypto {
public:
// ctx_byte: 0x00 = client→server direction, 0x01 = server→client direction.
static std::unique_ptr<SodiumMediaCrypto> derive(TlsContext& tls, uint8_t ctx_byte);
// Convenience: derive the key used to encrypt outgoing frames.
// is_client=true → ctx=0x00 (client sends); is_client=false → ctx=0x01 (server sends).
static std::unique_ptr<SodiumMediaCrypto> derive_send(TlsContext& tls, bool is_client);
// Convenience: derive the key used to decrypt incoming frames.
// is_client=true → ctx=0x01 (client recvs); is_client=false → ctx=0x00 (server recvs).
static std::unique_ptr<SodiumMediaCrypto> derive_recv(TlsContext& tls, bool is_client);
// Unit-test constructor: supply a raw 32-byte key directly.
explicit SodiumMediaCrypto(const uint8_t key[crypto_aead_chacha20poly1305_ietf_KEYBYTES]);
// Returns the send counter for the NEXT seal() call (use as frame seq).
uint64_t peek_send_counter() const { return send_counter_; }
// seal(): increments send_counter_; nonce derived from internal counter.
long seal(const uint8_t* plain, size_t len, const uint8_t* aad, size_t aad_len,
uint8_t* out, size_t out_cap) override;
// open(): reconstructs counter from aad[8..9] (seq field), checks anti-replay.
long open(const uint8_t* sealed, size_t len, const uint8_t* aad, size_t aad_len,
uint8_t* out, size_t out_cap) override;
private:
void build_nonce(uint64_t counter, uint8_t nonce[12]) const;
std::array<uint8_t, crypto_aead_chacha20poly1305_ietf_KEYBYTES> key_{};
// Send state (used only in seal()).
uint64_t send_counter_{0};
// Receive anti-replay state (used only in open()).
// Window: highest accepted counter + bitmask of last 64 accepted counters.
uint64_t recv_highest_{0}; // highest counter seen and accepted
uint64_t recv_window_{0}; // bit i set → (highest - i) was accepted
bool recv_initialized_{false}; // first packet initializes the window
};
} // namespace voicecat::crypto
#else // !VOICECAT_HAS_NET — skeleton stubs
@@ -138,6 +188,13 @@ class MediaCrypto {
virtual long open(const uint8_t*, size_t, const uint8_t*, size_t, uint8_t*, size_t) = 0;
};
class SodiumMediaCrypto final : public MediaCrypto {
public:
long seal(const uint8_t*, size_t, const uint8_t*, size_t, uint8_t*, size_t) override { return -1; }
long open(const uint8_t*, size_t, const uint8_t*, size_t, uint8_t*, size_t) override { return -1; }
uint64_t peek_send_counter() const { return 0; }
};
} // namespace voicecat::crypto
#endif // VOICECAT_HAS_NET