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