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:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user