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

@@ -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;