fix(media): stop permanent voice loss after bad-network blip (protocol v2)

A bad UDP packet on a flaky link could permanently wedge the voice path,
unrecoverable even across app restarts. Three defects:

1. Anti-replay window was advanced from the UNAUTHENTICATED header seq
   before the AEAD tag was checked, and not rolled back on failure. One
   corrupted/forged frame shoved recv_highest_ far ahead, after which every
   legitimate frame was rejected as "too old" forever. Reorder to
   replay-check -> authenticate -> update (RFC 3711 3.3); the window now
   moves only after a successful tag check.

2. The wire seq was only the low 16 bits of the nonce counter (zero-extended
   on receive). After 65,536 frames the nonce desynced and all frames failed
   auth. Widen the voice frame seq u16 -> u64 (header 14 -> 20 bytes). The
   core owns all UDP framing, so Swift/C# clients need only a rebuild. This
   is a versioned wire change: VOICECAT_PROTOCOL_VERSION 1 -> 2, handshake
   rejects on mismatch.

3. Server leaked per-session UDP state on disconnect; unregister_session now
   frees udp_endpoints_/udp_tokens_/ssrc_to_session_.

Also add rate-limited dropped-frame logging to MediaRelay so a wedged media
path is observable. New regression tests in test_media_aead.cpp cover the
poison (fails on old code) and the 16-bit wrap. ctest --preset dev
-E external_pcm: 22/22 pass (external_pcm aborts on a pre-existing CoreAudio
shutdown race, unrelated).
This commit is contained in:
2026-06-21 17:45:28 +02:00
parent 5be6d8430d
commit 6071c8e238
17 changed files with 299 additions and 86 deletions

View File

@@ -42,7 +42,7 @@ namespace voicecat::audio {
class JitterBuffer {
public:
struct Frame {
uint16_t seq;
uint64_t seq;
uint32_t timestamp;
bool fec_present;
std::vector<uint8_t> payload;

View File

@@ -298,7 +298,7 @@ void vc_client::run_io(std::string host, uint16_t port) {
voicecat::v1::Envelope env;
env.set_request_id(next_req_id_++);
auto* hello = env.mutable_client_hello();
hello->set_proto_version(1);
hello->set_proto_version(2);
hello->set_client_name(cfg_.client_name ? cfg_.client_name : "vccli");
hello->set_client_version(cfg_.client_version ? cfg_.client_version : "0.1.0");
auto frame = make_frame(env);
@@ -1047,7 +1047,7 @@ void vc_client::on_capture_frame(int kind, const int16_t* pcm, int samples, int
voicecat::net::VoiceFrame hdr;
hdr.ssrc = ls.ssrc;
hdr.seq = static_cast<uint16_t>(media_send_crypto_->peek_send_counter());
hdr.seq = media_send_crypto_->peek_send_counter();
hdr.timestamp = ls.timestamp;
ls.timestamp += static_cast<uint32_t>(samples);

View File

@@ -353,24 +353,28 @@ long SodiumMediaCrypto::open(const uint8_t* sealed, size_t len, const uint8_t* a
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];
// Read the full 64-bit nonce counter directly from aad[8..15] (seq, big-endian
// u64). Protocol v2 carries the full counter on the wire, so the nonce is exact —
// no reconstruction/rollover guessing needed.
if (aad_len < 16) return -1;
uint64_t counter = (static_cast<uint64_t>(aad[8]) << 56) |
(static_cast<uint64_t>(aad[9]) << 48) |
(static_cast<uint64_t>(aad[10]) << 40) |
(static_cast<uint64_t>(aad[11]) << 32) |
(static_cast<uint64_t>(aad[12]) << 24) |
(static_cast<uint64_t>(aad[13]) << 16) |
(static_cast<uint64_t>(aad[14]) << 8) |
static_cast<uint64_t>(aad[15]);
// ── 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;
}
// ── Anti-replay: REJECT-ONLY checks (no state mutation) ───────────────────
// The counter comes from the UNAUTHENTICATED header, so we must NOT advance the
// window before the AEAD tag is verified — otherwise a single corrupted/forged
// packet would shove recv_highest_ far ahead and reject every later legitimate
// packet as "too old", permanently wedging the stream. Order per RFC 3711 §3.3:
// replay-check → authenticate → update.
if (recv_initialized_ && counter <= recv_highest_) {
uint64_t offset = recv_highest_ - counter;
if (offset >= 64) return -1; // too old
if (offset >= 64) return -1; // too old
if (recv_window_ & (UINT64_C(1) << offset)) return -1; // replay
}
@@ -382,11 +386,21 @@ long SodiumMediaCrypto::open(const uint8_t* sealed, size_t len, const uint8_t* a
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;
return -1; // auth failure — leave the replay window untouched
// Mark this counter as accepted in the window.
uint64_t offset = recv_highest_ - counter;
recv_window_ |= (UINT64_C(1) << offset);
// ── Authenticated: now it's safe to advance the window ────────────────────
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_window_ |= 1; // bit0 = the new highest
recv_highest_ = counter;
} else {
recv_window_ |= (UINT64_C(1) << (recv_highest_ - counter));
}
return static_cast<long>(plain_len);
}

View File

@@ -163,7 +163,9 @@ class SodiumMediaCrypto final : public MediaCrypto {
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.
// open(): reads the full 64-bit counter from aad[8..15] (seq field), checks
// anti-replay, then decrypts. The replay window is advanced ONLY after the AEAD
// tag verifies, so a corrupted/forged packet cannot poison it (RFC 3711 §3.3).
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;

View File

@@ -29,32 +29,36 @@ inline constexpr uint8_t kFlagLast = 0x08; // last frame before stream st
inline constexpr uint16_t kCodecOpus = 0;
// Size of the serialized header (bytes before the payload).
inline constexpr size_t kVoiceHeaderSize = 14;
inline constexpr size_t kVoiceHeaderSize = 20;
/*
* Wire layout (big-endian):
* [0] type u8
* [1] flags u8
* [2..3] codec u16
* [4..7] ssrc u32
* [8..9] seq u16 (low 16 bits of monotonic send counter)
* [10..13] timestamp u32 (sample clock @48 kHz)
* [14+] payload (AEAD-encrypted Opus packet)
* [0] type u8
* [1] flags u8
* [2..3] codec u16
* [4..7] ssrc u32
* [8..15] seq u64 (full monotonic send counter — the AEAD nonce counter)
* [16..19] timestamp u32 (sample clock @48 kHz)
* [20+] payload (AEAD-encrypted Opus packet)
*
* The 14-byte header is the AEAD AAD (authenticated, not encrypted).
* The 20-byte header is the AEAD AAD (authenticated, not encrypted).
* The payload region is the AEAD ciphertext + 16-byte Poly1305 MAC.
*
* Protocol v2 widened `seq` from u16 to u64: the receiver reconstructs the AEAD
* nonce counter directly from this field, so the full 64-bit counter must be on the
* wire (a 16-bit field wrapped after 65,536 frames and desynced the nonce).
*/
struct VoiceFrame {
uint8_t type = kFrameVoice;
uint8_t flags = 0;
uint16_t codec = kCodecOpus;
uint32_t ssrc = 0;
uint16_t seq = 0;
uint64_t seq = 0;
uint32_t timestamp = 0;
std::vector<uint8_t> payload; // Opus bytes (pre-AEAD on send; post-AEAD on recv)
};
// Serialize the 14-byte header into buf[0..13]. buf must be at least kVoiceHeaderSize bytes.
// Serialize the 20-byte header into buf[0..19]. buf must be at least kVoiceHeaderSize bytes.
inline void serialize_header(const VoiceFrame& f, uint8_t* buf) {
buf[0] = f.type;
buf[1] = f.flags;
@@ -64,15 +68,21 @@ inline void serialize_header(const VoiceFrame& f, uint8_t* buf) {
buf[5] = static_cast<uint8_t>(f.ssrc >> 16);
buf[6] = static_cast<uint8_t>(f.ssrc >> 8);
buf[7] = static_cast<uint8_t>(f.ssrc & 0xFF);
buf[8] = static_cast<uint8_t>(f.seq >> 8);
buf[9] = static_cast<uint8_t>(f.seq & 0xFF);
buf[10] = static_cast<uint8_t>(f.timestamp >> 24);
buf[11] = static_cast<uint8_t>(f.timestamp >> 16);
buf[12] = static_cast<uint8_t>(f.timestamp >> 8);
buf[13] = static_cast<uint8_t>(f.timestamp & 0xFF);
buf[8] = static_cast<uint8_t>(f.seq >> 56);
buf[9] = static_cast<uint8_t>(f.seq >> 48);
buf[10] = static_cast<uint8_t>(f.seq >> 40);
buf[11] = static_cast<uint8_t>(f.seq >> 32);
buf[12] = static_cast<uint8_t>(f.seq >> 24);
buf[13] = static_cast<uint8_t>(f.seq >> 16);
buf[14] = static_cast<uint8_t>(f.seq >> 8);
buf[15] = static_cast<uint8_t>(f.seq & 0xFF);
buf[16] = static_cast<uint8_t>(f.timestamp >> 24);
buf[17] = static_cast<uint8_t>(f.timestamp >> 16);
buf[18] = static_cast<uint8_t>(f.timestamp >> 8);
buf[19] = static_cast<uint8_t>(f.timestamp & 0xFF);
}
// Parse the 14-byte header from buf. Returns false if len < kVoiceHeaderSize.
// Parse the 20-byte header from buf. Returns false if len < kVoiceHeaderSize.
inline bool parse_header(const uint8_t* buf, size_t len, VoiceFrame& out) {
if (len < kVoiceHeaderSize) return false;
out.type = buf[0];
@@ -82,11 +92,18 @@ inline bool parse_header(const uint8_t* buf, size_t len, VoiceFrame& out) {
(static_cast<uint32_t>(buf[5]) << 16) |
(static_cast<uint32_t>(buf[6]) << 8) |
static_cast<uint32_t>(buf[7]);
out.seq = static_cast<uint16_t>((buf[8] << 8) | buf[9]);
out.timestamp = (static_cast<uint32_t>(buf[10]) << 24) |
(static_cast<uint32_t>(buf[11]) << 16) |
(static_cast<uint32_t>(buf[12]) << 8) |
static_cast<uint32_t>(buf[13]);
out.seq = (static_cast<uint64_t>(buf[8]) << 56) |
(static_cast<uint64_t>(buf[9]) << 48) |
(static_cast<uint64_t>(buf[10]) << 40) |
(static_cast<uint64_t>(buf[11]) << 32) |
(static_cast<uint64_t>(buf[12]) << 24) |
(static_cast<uint64_t>(buf[13]) << 16) |
(static_cast<uint64_t>(buf[14]) << 8) |
static_cast<uint64_t>(buf[15]);
out.timestamp = (static_cast<uint32_t>(buf[16]) << 24) |
(static_cast<uint32_t>(buf[17]) << 16) |
(static_cast<uint32_t>(buf[18]) << 8) |
static_cast<uint32_t>(buf[19]);
return true;
}