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