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

@@ -10,6 +10,35 @@ up instantly. Newest status at the top.
## ▶ Where we left off / next action ## ▶ Where we left off / next action
- **Done (2026-06-21):** **Fix permanent voice-loss bug + harden the UDP media path (protocol v2).**
Field report: two iOS users lost all audio mid-call after a bad-network blip and could not
recover even by restarting the apps. Root causes found in the UDP media path:
1. **Anti-replay window poisoned by unauthenticated packets (the trigger).**
`SodiumMediaCrypto::open()` advanced `recv_highest_` from the plaintext header `seq`
*before* verifying the AEAD tag and never rolled it back on failure. One corrupted/forged
frame (a bit-flip on flaky wifi) shoved the high-water mark far ahead, after which every
legitimate frame was rejected as "too old" — permanently. Fixed by reordering to
replay-check → authenticate → update (RFC 3711 §3.3): the window is now touched only after
a successful tag check. Regression test in `test_media_aead.cpp`
(`test_corrupted_seq_does_not_poison_window`) — fails on the old code, passes now.
2. **16-bit seq wrap with no rollover counter.** The wire header carried only the low 16 bits
of the nonce counter (zero-extended on receive); after 65,536 frames the reconstructed
nonce diverged and all frames failed auth. **Wire format widened to a full u64 seq**
(`voice_frame.h`: header 14 → 20 bytes, `seq` u16 → u64; `crypto.cpp`, `client.cpp`,
`media_relay.cpp` updated; `JitterBuffer::Frame::seq` widened). This is a **versioned wire
change → `VOICECAT_PROTOCOL_VERSION` 1 → 2**; the `Hello` handshake rejects on mismatch
(`conn_session.cpp`). The voice frame is parsed only in `core/`+`server/`+`tests/`, so the
Swift/C# clients need only a rebuild — no parser changes.
3. **Server leaked UDP state on disconnect.** `SessionRegistry::unregister_session()` now also
frees `udp_endpoints_`/`udp_tokens_`/`ssrc_to_session_` (scan-and-erase by session id).
4. **Diagnostics.** `MediaRelay` now emits rate-limited dropped-frame counters
(unmapped-endpoint / no-recv-crypto / open-failed) so a wedged media path is observable.
- **Verified:** `cmake --build --preset dev` clean; `ctest --preset dev -E external_pcm`
**22/22 pass** (incl. `m2_voice` e2e relay + the two new AEAD regressions). `external_pcm`
still aborts on the **pre-existing** CoreAudio shutdown mutex race (confirmed identical on a
clean baseline checkout under the same harness — unrelated to these changes). Docs updated:
`voice.md` §2 (header), `protocol.md` (v2 + negotiation), `security.md` (authenticate-then-advance).
- **Done (2026-06-21):** **Expose all channel codec params + guest nickname in every client.** - **Done (2026-06-21):** **Expose all channel codec params + guest nickname in every client.**
- **DRED everywhere + ABI fix.** `dred` (Opus 1.6 Deep REDundancy) existed in the C ABI - **DRED everywhere + ABI fix.** `dred` (Opus 1.6 Deep REDundancy) existed in the C ABI
(`vc_audio_config.dred`) and proto but was absent from *both* client marshaling layers — a (`vc_audio_config.dred`) and proto but was absent from *both* client marshaling layers — a

View File

@@ -48,8 +48,10 @@ extern "C" {
#define VOICECAT_VERSION_MINOR 0 #define VOICECAT_VERSION_MINOR 0
#define VOICECAT_VERSION_PATCH 1 #define VOICECAT_VERSION_PATCH 1
/* The control-protocol version this build speaks (docs/protocol.md §4). */ /* The control-protocol version this build speaks (docs/protocol.md §4).
#define VOICECAT_PROTOCOL_VERSION 1 * v2 widened the UDP voice frame seq field u16 → u64 (docs/voice.md §2); a v2 server
* and a v1 client cannot interoperate, so the Hello handshake rejects on mismatch. */
#define VOICECAT_PROTOCOL_VERSION 2
/* ── Result codes ─────────────────────────────────────────────────────────── */ /* ── Result codes ─────────────────────────────────────────────────────────── */
typedef enum vc_result { typedef enum vc_result {

View File

@@ -42,7 +42,7 @@ namespace voicecat::audio {
class JitterBuffer { class JitterBuffer {
public: public:
struct Frame { struct Frame {
uint16_t seq; uint64_t seq;
uint32_t timestamp; uint32_t timestamp;
bool fec_present; bool fec_present;
std::vector<uint8_t> payload; 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; voicecat::v1::Envelope env;
env.set_request_id(next_req_id_++); env.set_request_id(next_req_id_++);
auto* hello = env.mutable_client_hello(); 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_name(cfg_.client_name ? cfg_.client_name : "vccli");
hello->set_client_version(cfg_.client_version ? cfg_.client_version : "0.1.0"); hello->set_client_version(cfg_.client_version ? cfg_.client_version : "0.1.0");
auto frame = make_frame(env); 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; voicecat::net::VoiceFrame hdr;
hdr.ssrc = ls.ssrc; 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; hdr.timestamp = ls.timestamp;
ls.timestamp += static_cast<uint32_t>(samples); ls.timestamp += static_cast<uint32_t>(samples);

View File

@@ -353,22 +353,26 @@ long SodiumMediaCrypto::open(const uint8_t* sealed, size_t len, const uint8_t* a
if (len < crypto_aead_chacha20poly1305_ietf_ABYTES) return -1; if (len < crypto_aead_chacha20poly1305_ietf_ABYTES) return -1;
if (out_cap < 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). // Read the full 64-bit nonce counter directly from aad[8..15] (seq, big-endian
// For M2, we zero-extend the 16-bit seq; TODO: add ROC for long sessions. // u64). Protocol v2 carries the full counter on the wire, so the nonce is exact —
if (aad_len < 10) return -1; // no reconstruction/rollover guessing needed.
uint64_t counter = (static_cast<uint64_t>(aad[8]) << 8) | aad[9]; 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 ──────────────────────────────────────────────────── // ── Anti-replay: REJECT-ONLY checks (no state mutation) ───────────────────
if (!recv_initialized_) { // The counter comes from the UNAUTHENTICATED header, so we must NOT advance the
recv_highest_ = counter; // window before the AEAD tag is verified — otherwise a single corrupted/forged
recv_window_ = 1; // bit0 = highest itself // packet would shove recv_highest_ far ahead and reject every later legitimate
recv_initialized_ = true; // packet as "too old", permanently wedging the stream. Order per RFC 3711 §3.3:
} else { // replay-check → authenticate → update.
if (counter > recv_highest_) { if (recv_initialized_ && 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; 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 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), out, &plain_len, nullptr, sealed, static_cast<unsigned long long>(len),
aad, static_cast<unsigned long long>(aad_len), aad, static_cast<unsigned long long>(aad_len),
nonce, key_.data()) != 0) nonce, key_.data()) != 0)
return -1; return -1; // auth failure — leave the replay window untouched
// Mark this counter as accepted in the window. // ── Authenticated: now it's safe to advance the window ────────────────────
uint64_t offset = recv_highest_ - counter; if (!recv_initialized_) {
recv_window_ |= (UINT64_C(1) << offset); 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); 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, 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; 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, 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; uint8_t* out, size_t out_cap) override;

View File

@@ -29,7 +29,7 @@ inline constexpr uint8_t kFlagLast = 0x08; // last frame before stream st
inline constexpr uint16_t kCodecOpus = 0; inline constexpr uint16_t kCodecOpus = 0;
// Size of the serialized header (bytes before the payload). // 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): * Wire layout (big-endian):
@@ -37,24 +37,28 @@ inline constexpr size_t kVoiceHeaderSize = 14;
* [1] flags u8 * [1] flags u8
* [2..3] codec u16 * [2..3] codec u16
* [4..7] ssrc u32 * [4..7] ssrc u32
* [8..9] seq u16 (low 16 bits of monotonic send counter) * [8..15] seq u64 (full monotonic send counter — the AEAD nonce counter)
* [10..13] timestamp u32 (sample clock @48 kHz) * [16..19] timestamp u32 (sample clock @48 kHz)
* [14+] payload (AEAD-encrypted Opus packet) * [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. * 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 { struct VoiceFrame {
uint8_t type = kFrameVoice; uint8_t type = kFrameVoice;
uint8_t flags = 0; uint8_t flags = 0;
uint16_t codec = kCodecOpus; uint16_t codec = kCodecOpus;
uint32_t ssrc = 0; uint32_t ssrc = 0;
uint16_t seq = 0; uint64_t seq = 0;
uint32_t timestamp = 0; uint32_t timestamp = 0;
std::vector<uint8_t> payload; // Opus bytes (pre-AEAD on send; post-AEAD on recv) 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) { inline void serialize_header(const VoiceFrame& f, uint8_t* buf) {
buf[0] = f.type; buf[0] = f.type;
buf[1] = f.flags; 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[5] = static_cast<uint8_t>(f.ssrc >> 16);
buf[6] = static_cast<uint8_t>(f.ssrc >> 8); buf[6] = static_cast<uint8_t>(f.ssrc >> 8);
buf[7] = static_cast<uint8_t>(f.ssrc & 0xFF); buf[7] = static_cast<uint8_t>(f.ssrc & 0xFF);
buf[8] = static_cast<uint8_t>(f.seq >> 8); buf[8] = static_cast<uint8_t>(f.seq >> 56);
buf[9] = static_cast<uint8_t>(f.seq & 0xFF); buf[9] = static_cast<uint8_t>(f.seq >> 48);
buf[10] = static_cast<uint8_t>(f.timestamp >> 24); buf[10] = static_cast<uint8_t>(f.seq >> 40);
buf[11] = static_cast<uint8_t>(f.timestamp >> 16); buf[11] = static_cast<uint8_t>(f.seq >> 32);
buf[12] = static_cast<uint8_t>(f.timestamp >> 8); buf[12] = static_cast<uint8_t>(f.seq >> 24);
buf[13] = static_cast<uint8_t>(f.timestamp & 0xFF); 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) { inline bool parse_header(const uint8_t* buf, size_t len, VoiceFrame& out) {
if (len < kVoiceHeaderSize) return false; if (len < kVoiceHeaderSize) return false;
out.type = buf[0]; 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[5]) << 16) |
(static_cast<uint32_t>(buf[6]) << 8) | (static_cast<uint32_t>(buf[6]) << 8) |
static_cast<uint32_t>(buf[7]); static_cast<uint32_t>(buf[7]);
out.seq = static_cast<uint16_t>((buf[8] << 8) | buf[9]); out.seq = (static_cast<uint64_t>(buf[8]) << 56) |
out.timestamp = (static_cast<uint32_t>(buf[10]) << 24) | (static_cast<uint64_t>(buf[9]) << 48) |
(static_cast<uint32_t>(buf[11]) << 16) | (static_cast<uint64_t>(buf[10]) << 40) |
(static_cast<uint32_t>(buf[12]) << 8) | (static_cast<uint64_t>(buf[11]) << 32) |
static_cast<uint32_t>(buf[13]); (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; return true;
} }

View File

@@ -153,6 +153,9 @@ Notes:
- **Version negotiation.** Each side sends `proto_version` (integer) and a `features` - **Version negotiation.** Each side sends `proto_version` (integer) and a `features`
string list. The effective version is `min(client, server)`; the effective feature set string list. The effective version is `min(client, server)`; the effective feature set
is the intersection. A client that doesn't understand a feature simply never uses it. is the intersection. A client that doesn't understand a feature simply never uses it.
The **current `proto_version` is 2**. v2 widened the UDP voice frame `seq` field from
u16 to u64 (voice.md §2) — a wire-format change with no backward compatibility on the
media path, so the server rejects any peer not on v2 rather than min-negotiating down.
- **Auth over TLS.** Passwords cross the wire only inside TLS 1.3 and are verified against - **Auth over TLS.** Passwords cross the wire only inside TLS 1.3 and are verified against
an Argon2id hash at rest (see security.md). `auth_methods` in `ServerHello` advertises an Argon2id hash at rest (see security.md). `auth_methods` in `ServerHello` advertises
whether `guest` is enabled. whether `guest` is enabled.

View File

@@ -89,8 +89,13 @@ the design depends on that.
- **Nonce discipline:** `nonce = direction_bit ‖ ssrc ‖ monotonic_packet_counter`. The - **Nonce discipline:** `nonce = direction_bit ‖ ssrc ‖ monotonic_packet_counter`. The
counter never repeats under one key; the session **rekeys** (re-derives via the exporter counter never repeats under one key; the session **rekeys** (re-derives via the exporter
with a bumped epoch) well before counter exhaustion or on a time/byte budget. with a bumped epoch) well before counter exhaustion or on a time/byte budget.
- **Anti-replay:** a sliding-window replay filter per ssrc (à la IPsec) keyed on the packet - **Anti-replay:** a 64-bit sliding-window replay filter keyed on the packet counter (à la
counter. Replays and out-of-window packets are dropped before decode. IPsec). The window is **advanced only after the AEAD tag verifies** (RFC 3711 §3.3 order:
replay-check → authenticate → update). The counter is read from the unauthenticated
header, so advancing the high-water mark *before* authentication would let a single
corrupted or forged packet jump it far ahead, after which every legitimate packet is
rejected as "too old" — a permanent denial of the whole stream. Failed-auth packets leave
the window untouched. Replays and out-of-window packets are dropped before decode.
## 3. UDP session binding ## 3. UDP session binding

View File

@@ -35,13 +35,16 @@ macOS, **and iOS** (via a ReplayKit broadcast extension).
A fixed binary header — no protobuf on the RT path. Multi-byte fields are big-endian. A fixed binary header — no protobuf on the RT path. Multi-byte fields are big-endian.
The header is **20 bytes** (protocol v2; v1 was 14 bytes with a u16 seq — see note below).
``` ```
0 1 2 3 4 5 6 7 8 ... 0 1 2 3 4 5 6 7 8 ............ 15
┌──────┬──────┬──────┬──────┬──────┬──────┬──────┬──────┬───────────────┐ ┌──────┬──────┬──────┬──────┬──────┬──────┬──────┬──────┬───────────────┐
│ type │flags │ codec │ ssrc (u32) │ type │flags │ codec │ ssrc (u32) seq (u64) ──▶
├──────┴──────┴──────┴────────────────────────────────────────────┤ ├──────┴──────┴──────┴────────────────────────┴─────────────────────┤
│ seq (u16) │ timestamp (u32, in samples @48k) │ payload ... │ ◀── seq (u64) ──┤ timestamp (u32 @48k) │ payload ...
└─────────────┴──────────────────────────────────────────┴──────────────┘ └──────────────────┴────────────────────────────────────┴──────────────┘
bytes [8..15] = seq (u64) [16..19] = timestamp (u32)
type u8 1 = VOICE, 2 = KEEPALIVE, 3 = UDP_BINDING (handshake) type u8 1 = VOICE, 2 = KEEPALIVE, 3 = UDP_BINDING (handshake)
flags u8 bit0 marker (start of talkspurt) · bit1 FEC-present flags u8 bit0 marker (start of talkspurt) · bit1 FEC-present
@@ -49,11 +52,19 @@ flags u8 bit0 marker (start of talkspurt) · bit1 FEC-present
codec u16 0 = OPUS (room for future codecs) codec u16 0 = OPUS (room for future codecs)
ssrc u32 media-plane stream id. Client sends its own ssrc; the server ssrc u32 media-plane stream id. Client sends its own ssrc; the server
validates it against the bound session and relays unchanged. validates it against the bound session and relays unchanged.
seq u16 per-ssrc sequence number, wraps; drives loss detection + reorder seq u64 full monotonic send counter. This IS the AEAD nonce counter, so the
receiver derives the nonce directly from it — no rollover guessing.
timestamp u32 RTP-style sample clock @48 kHz; drives the jitter buffer timestamp u32 RTP-style sample clock @48 kHz; drives the jitter buffer
payload one Opus packet (the encoder's output for one frame) payload one Opus packet (the encoder's output for one frame)
``` ```
> **Why u64 (protocol v2).** v1 carried only the low 16 bits of the counter and the
> receiver zero-extended them to rebuild the AEAD nonce. After 65,536 frames the seq
> wrapped, the reconstructed nonce diverged from the sealing nonce, and **every frame
> failed authentication permanently** (no rollover counter). v2 puts the full 64-bit
> counter on the wire so the nonce is always exact. A v2 server and a v1 client cannot
> interoperate; the `Hello` handshake rejects on `proto_version` mismatch.
This is intentionally RTP-shaped (familiar semantics: ssrc/seq/timestamp) without RTP's This is intentionally RTP-shaped (familiar semantics: ssrc/seq/timestamp) without RTP's
full machinery. The **server relays the payload unmodified** — it only reads the header to full machinery. The **server relays the payload unmodified** — it only reads the header to
route by ssrc→channel and may restamp nothing (the client's ssrc is globally unique once route by ssrc→channel and may restamp nothing (the client's ssrc is globally unique once

View File

@@ -257,13 +257,13 @@ void ConnSession::send_generic_result(uint64_t req_id, bool ok, uint32_t code,
// ── Handlers ───────────────────────────────────────────────────────────────── // ── Handlers ─────────────────────────────────────────────────────────────────
void ConnSession::handle_client_hello(uint64_t req_id, const voicecat::v1::ClientHello& msg) { void ConnSession::handle_client_hello(uint64_t req_id, const voicecat::v1::ClientHello& msg) {
if (msg.proto_version() != 1) { if (msg.proto_version() != 2) { // protocol v2: 64-bit voice seq (docs/voice.md §2)
send_disconnect_and_close(1, "unsupported protocol version"); send_disconnect_and_close(1, "unsupported protocol version");
return; return;
} }
auto env = make_env(req_id); auto env = make_env(req_id);
auto* hello = env.mutable_server_hello(); auto* hello = env.mutable_server_hello();
hello->set_proto_version(1); hello->set_proto_version(2);
hello->set_server_name("VoiceCat Server"); hello->set_server_name("VoiceCat Server");
hello->set_server_version("0.1.0"); hello->set_server_version("0.1.0");
if (allow_guests_) hello->add_auth_methods("guest"); if (allow_guests_) hello->add_auth_methods("guest");

View File

@@ -3,7 +3,9 @@
#ifdef VOICECAT_HAS_NET #ifdef VOICECAT_HAS_NET
#include <array> #include <array>
#include <chrono>
#include <cstdio> #include <cstdio>
#include <string_view>
#include "conn_session.h" #include "conn_session.h"
#include "crypto/crypto.h" #include "crypto/crypto.h"
@@ -35,6 +37,24 @@ uint16_t MediaRelay::media_port() const {
return static_cast<uint16_t>(udp_.local_endpoint().port()); return static_cast<uint16_t>(udp_.local_endpoint().port());
} }
void MediaRelay::note_drop(const char* reason) {
if (std::string_view(reason) == "unmapped-endpoint") ++drop_no_endpoint_;
else if (std::string_view(reason) == "no-recv-crypto") ++drop_no_crypto_;
else ++drop_open_failed_;
// Rate-limit the summary to at most once every 5s so a flood can't spam the log.
auto now_ms = std::chrono::duration_cast<std::chrono::milliseconds>(
std::chrono::steady_clock::now().time_since_epoch()).count();
if (now_ms - last_drop_log_ms_ < 5000) return;
last_drop_log_ms_ = now_ms;
std::fprintf(stderr,
"[media] dropped frames — unmapped-endpoint=%llu no-recv-crypto=%llu "
"open-failed(auth/replay)=%llu\n",
static_cast<unsigned long long>(drop_no_endpoint_),
static_cast<unsigned long long>(drop_no_crypto_),
static_cast<unsigned long long>(drop_open_failed_));
}
void MediaRelay::on_udp_frame(const uint8_t* data, size_t len, void MediaRelay::on_udp_frame(const uint8_t* data, size_t len,
asio::ip::udp::endpoint sender) { asio::ip::udp::endpoint sender) {
if (len < 1) return; if (len < 1) return;
@@ -59,10 +79,10 @@ void MediaRelay::on_udp_frame(const uint8_t* data, size_t len,
// Resolve sender session. // Resolve sender session.
auto sender_session = registry_->find_by_udp_endpoint(sender); auto sender_session = registry_->find_by_udp_endpoint(sender);
if (!sender_session) return; if (!sender_session) { note_drop("unmapped-endpoint"); return; }
auto* recv_crypto = sender_session->recv_crypto(); auto* recv_crypto = sender_session->recv_crypto();
if (!recv_crypto) return; if (!recv_crypto) { note_drop("no-recv-crypto"); return; }
// AAD = 14-byte header (authenticated, not encrypted). // AAD = 14-byte header (authenticated, not encrypted).
const uint8_t* aad = data; const uint8_t* aad = data;
@@ -73,7 +93,7 @@ void MediaRelay::on_udp_frame(const uint8_t* data, size_t len,
long plain_len = recv_crypto->open(sealed, sealed_len, aad, voicecat::net::kVoiceHeaderSize, long plain_len = recv_crypto->open(sealed, sealed_len, aad, voicecat::net::kVoiceHeaderSize,
plain_buf_.data(), plain_buf_.size()); plain_buf_.data(), plain_buf_.size());
if (plain_len < 0) return; // auth failure or replay if (plain_len < 0) { note_drop("open-failed"); return; } // auth failure or replay
// Parse the voice frame header to find the source ssrc/channel. // Parse the voice frame header to find the source ssrc/channel.
voicecat::net::VoiceFrame hdr{}; voicecat::net::VoiceFrame hdr{};
@@ -111,8 +131,14 @@ void MediaRelay::on_udp_frame(const uint8_t* data, size_t len,
// so the header (which is the authenticated AAD) carries the matching counter. // so the header (which is the authenticated AAD) carries the matching counter.
std::memcpy(seal_buf_.data(), data, voicecat::net::kVoiceHeaderSize); std::memcpy(seal_buf_.data(), data, voicecat::net::kVoiceHeaderSize);
const uint64_t send_ctr = send_crypto->peek_send_counter(); const uint64_t send_ctr = send_crypto->peek_send_counter();
seal_buf_[8] = static_cast<uint8_t>((send_ctr >> 8) & 0xFF); seal_buf_[8] = static_cast<uint8_t>((send_ctr >> 56) & 0xFF);
seal_buf_[9] = static_cast<uint8_t>(send_ctr & 0xFF); seal_buf_[9] = static_cast<uint8_t>((send_ctr >> 48) & 0xFF);
seal_buf_[10] = static_cast<uint8_t>((send_ctr >> 40) & 0xFF);
seal_buf_[11] = static_cast<uint8_t>((send_ctr >> 32) & 0xFF);
seal_buf_[12] = static_cast<uint8_t>((send_ctr >> 24) & 0xFF);
seal_buf_[13] = static_cast<uint8_t>((send_ctr >> 16) & 0xFF);
seal_buf_[14] = static_cast<uint8_t>((send_ctr >> 8) & 0xFF);
seal_buf_[15] = static_cast<uint8_t>(send_ctr & 0xFF);
uint8_t* out_payload = seal_buf_.data() + voicecat::net::kVoiceHeaderSize; uint8_t* out_payload = seal_buf_.data() + voicecat::net::kVoiceHeaderSize;
long sealed_out = send_crypto->seal( long sealed_out = send_crypto->seal(

View File

@@ -49,10 +49,20 @@ class MediaRelay {
private: private:
void on_udp_frame(const uint8_t* data, size_t len, asio::ip::udp::endpoint sender); void on_udp_frame(const uint8_t* data, size_t len, asio::ip::udp::endpoint sender);
// Count a dropped inbound voice frame (by reason) and emit a rate-limited summary
// to stderr. Runs on the io thread, so plain counters are safe.
void note_drop(const char* reason);
asio::io_context& io_; asio::io_context& io_;
std::shared_ptr<SessionRegistry> registry_; std::shared_ptr<SessionRegistry> registry_;
voicecat::net::UdpMediaChannel udp_; voicecat::net::UdpMediaChannel udp_;
// Diagnostics: dropped-frame counters so a wedged media path is observable.
uint64_t drop_no_endpoint_ = 0; // voice from an unmapped UDP endpoint
uint64_t drop_no_crypto_ = 0; // session has no recv_crypto yet
uint64_t drop_open_failed_ = 0; // AEAD auth failure or replay reject
int64_t last_drop_log_ms_ = 0;
// Scratch buffer for re-encrypted payloads (size = max_frame + 16 MAC) // Scratch buffer for re-encrypted payloads (size = max_frame + 16 MAC)
static constexpr size_t kMaxPayload = 1500; static constexpr size_t kMaxPayload = 1500;
std::vector<uint8_t> seal_buf_ = std::vector<uint8_t>(kMaxPayload + 16, uint8_t{0}); std::vector<uint8_t> seal_buf_ = std::vector<uint8_t>(kMaxPayload + 16, uint8_t{0});

View File

@@ -100,6 +100,22 @@ void SessionRegistry::unregister_session(uint64_t session_id) {
std::unique_lock lk(mu_); std::unique_lock lk(mu_);
sessions_.erase(session_id); sessions_.erase(session_id);
session_permissions_.erase(session_id); session_permissions_.erase(session_id);
// Free the per-session UDP/media state too. These maps are keyed by
// endpoint/token/ssrc (not session id), so scan-and-erase by value. Leaving them
// behind leaks entries and lets a stale endpoint/token resolve toward a dead
// session across reconnects (e.g. a wifi-handoff rebind from a new port).
auto erase_by_value = [session_id](auto& map) {
for (auto it = map.begin(); it != map.end();) {
if (it->second == session_id)
it = map.erase(it);
else
++it;
}
};
erase_by_value(udp_endpoints_);
erase_by_value(udp_tokens_);
erase_by_value(ssrc_to_session_);
} }
uint32_t SessionRegistry::add_user(uint64_t session_id, const voicecat::v1::User& user) { uint32_t SessionRegistry::add_user(uint64_t session_id, const voicecat::v1::User& user) {

View File

@@ -212,7 +212,7 @@ struct TestClient {
{ {
v1::Envelope env; v1::Envelope env;
env.set_request_id(1); env.set_request_id(1);
env.mutable_client_hello()->set_proto_version(1); env.mutable_client_hello()->set_proto_version(2);
env.mutable_client_hello()->set_client_name(label); env.mutable_client_hello()->set_client_name(label);
if (!tcp_send_envelope(*tls, env)) return false; if (!tcp_send_envelope(*tls, env)) return false;
} }
@@ -450,10 +450,10 @@ int main() {
size_t payload_len = pcm.size() * sizeof(int16_t); size_t payload_len = pcm.size() * sizeof(int16_t);
#endif #endif
// Build 14-byte header (AAD). // Build the voice frame header (AAD).
VoiceFrame hdr; VoiceFrame hdr;
hdr.ssrc = A.assigned_ssrc; hdr.ssrc = A.assigned_ssrc;
hdr.seq = static_cast<uint16_t>(i); hdr.seq = static_cast<uint64_t>(i);
hdr.timestamp = static_cast<uint32_t>(i * kFrameSamples); hdr.timestamp = static_cast<uint32_t>(i * kFrameSamples);
uint8_t header_bytes[kVoiceHeaderSize]; uint8_t header_bytes[kVoiceHeaderSize];
serialize_header(hdr, header_bytes); serialize_header(hdr, header_bytes);

View File

@@ -24,8 +24,8 @@ static int g_failures = 0;
++g_failures; \ ++g_failures; \
}} while (0) }} while (0)
// Build a synthetic 14-byte AAD (voice frame header). // Build a synthetic voice-frame-header AAD.
static std::vector<uint8_t> make_aad(uint16_t seq) { static std::vector<uint8_t> make_aad(uint64_t seq) {
VoiceFrame f; VoiceFrame f;
f.ssrc = 0xCAFEBABE; f.ssrc = 0xCAFEBABE;
f.seq = seq; f.seq = seq;
@@ -132,7 +132,7 @@ static void test_multiple_packets() {
std::vector<uint8_t> plain(60, 0x99); std::vector<uint8_t> plain(60, 0x99);
for (uint16_t seq = 0; seq < 10; ++seq) { for (uint64_t seq = 0; seq < 10; ++seq) {
auto aad = make_aad(seq); auto aad = make_aad(seq);
std::vector<uint8_t> cipher(plain.size() + crypto_aead_chacha20poly1305_ietf_ABYTES); std::vector<uint8_t> cipher(plain.size() + crypto_aead_chacha20poly1305_ietf_ABYTES);
long sealed_len = sender.seal(plain.data(), plain.size(), long sealed_len = sender.seal(plain.data(), plain.size(),
@@ -149,8 +149,8 @@ static void test_multiple_packets() {
} }
} }
// Build a 14-byte AAD (voice frame header) with a given ssrc + seq. // Build a voice-frame-header AAD with a given ssrc + seq.
static std::vector<uint8_t> make_aad_ssrc(uint32_t ssrc, uint16_t seq) { static std::vector<uint8_t> make_aad_ssrc(uint32_t ssrc, uint64_t seq) {
VoiceFrame f; VoiceFrame f;
f.ssrc = ssrc; f.ssrc = ssrc;
f.seq = seq; f.seq = seq;
@@ -159,6 +159,12 @@ static std::vector<uint8_t> make_aad_ssrc(uint32_t ssrc, uint16_t seq) {
return aad; return aad;
} }
// Overwrite the 8-byte big-endian seq field (header bytes [8..15]) in an AAD buffer.
static void set_aad_seq(std::vector<uint8_t>& aad, uint64_t seq) {
for (int i = 0; i < 8; ++i)
aad[8 + i] = static_cast<uint8_t>((seq >> (56 - 8 * i)) & 0xFF);
}
// Simulate one server relay hop for a single frame, sender → recipient R. // Simulate one server relay hop for a single frame, sender → recipient R.
// - sender seals with its send key, setting header seq = its own send counter (client contract). // - sender seals with its send key, setting header seq = its own send counter (client contract).
// - server opens with the sender's key, then re-seals with R's send key. // - server opens with the sender's key, then re-seals with R's send key.
@@ -169,7 +175,7 @@ static bool relay_one(SodiumMediaCrypto& sender_send, SodiumMediaCrypto& server_
SodiumMediaCrypto& r_send, SodiumMediaCrypto& r_recv, SodiumMediaCrypto& r_send, SodiumMediaCrypto& r_recv,
uint32_t ssrc, const std::vector<uint8_t>& plain, bool rewrite_seq) { uint32_t ssrc, const std::vector<uint8_t>& plain, bool rewrite_seq) {
// Client A→server: seq carries the sender's send counter. // Client A→server: seq carries the sender's send counter.
auto in_aad = make_aad_ssrc(ssrc, static_cast<uint16_t>(sender_send.peek_send_counter())); auto in_aad = make_aad_ssrc(ssrc, sender_send.peek_send_counter());
std::vector<uint8_t> cipher(plain.size() + crypto_aead_chacha20poly1305_ietf_ABYTES); std::vector<uint8_t> cipher(plain.size() + crypto_aead_chacha20poly1305_ietf_ABYTES);
long sealed = sender_send.seal(plain.data(), plain.size(), in_aad.data(), in_aad.size(), long sealed = sender_send.seal(plain.data(), plain.size(), in_aad.data(), in_aad.size(),
cipher.data(), cipher.size()); cipher.data(), cipher.size());
@@ -185,11 +191,7 @@ static bool relay_one(SodiumMediaCrypto& sender_send, SodiumMediaCrypto& server_
// Server re-seals to R. Header passes through except seq, which (when fixed) is set to R's // Server re-seals to R. Header passes through except seq, which (when fixed) is set to R's
// own send counter so R's open() reconstructs the matching nonce. // own send counter so R's open() reconstructs the matching nonce.
std::vector<uint8_t> out_aad = in_aad; // copy header verbatim std::vector<uint8_t> out_aad = in_aad; // copy header verbatim
if (rewrite_seq) { if (rewrite_seq) set_aad_seq(out_aad, r_send.peek_send_counter());
uint64_t ctr = r_send.peek_send_counter();
out_aad[8] = static_cast<uint8_t>((ctr >> 8) & 0xFF);
out_aad[9] = static_cast<uint8_t>(ctr & 0xFF);
}
std::vector<uint8_t> relay_cipher(recovered.size() + crypto_aead_chacha20poly1305_ietf_ABYTES); std::vector<uint8_t> relay_cipher(recovered.size() + crypto_aead_chacha20poly1305_ietf_ABYTES);
long resealed = r_send.seal(recovered.data(), static_cast<size_t>(opened), long resealed = r_send.seal(recovered.data(), static_cast<size_t>(opened),
out_aad.data(), out_aad.size(), out_aad.data(), out_aad.size(),
@@ -251,6 +253,79 @@ static void test_relay_interleaved_reseal() {
} }
} }
// Regression for the bad-wifi wedge: the anti-replay window must NOT be advanced by a
// packet that fails authentication. A single corrupted/forged frame carrying a huge seq
// used to shove recv_highest_ far ahead (before the AEAD tag was checked), after which
// every legitimate frame was rejected as "too old" — permanent silence. open() now
// advances the window only after a successful tag check (RFC 3711 §3.3).
static void test_corrupted_seq_does_not_poison_window() {
uint8_t key[crypto_aead_chacha20poly1305_ietf_KEYBYTES];
crypto_generichash(key, sizeof(key),
reinterpret_cast<const uint8_t*>("poison-key"), 10, nullptr, 0);
SodiumMediaCrypto sender(key);
SodiumMediaCrypto receiver(key);
std::vector<uint8_t> plain(64, 0x5A);
std::vector<uint8_t> recovered(plain.size());
auto seal_at_current = [&](std::vector<uint8_t>& aad_out, std::vector<uint8_t>& cipher_out) {
aad_out = make_aad_ssrc(0xABCD, sender.peek_send_counter());
cipher_out.assign(plain.size() + crypto_aead_chacha20poly1305_ietf_ABYTES, 0);
long s = sender.seal(plain.data(), plain.size(), aad_out.data(), aad_out.size(),
cipher_out.data(), cipher_out.size());
CHECK(s > 0);
};
// 1. A normal frame (counter 0) decrypts. recv_highest_ = 0.
std::vector<uint8_t> aad0, cipher0;
seal_at_current(aad0, cipher0); // sender counter 0 → 1
CHECK(receiver.open(cipher0.data(), cipher0.size(), aad0.data(), aad0.size(),
recovered.data(), recovered.size()) == static_cast<long>(plain.size()));
// 2. A frame whose header seq has been corrupted to a huge value: it fails auth
// (the AAD no longer matches what was sealed) and must NOT move the window.
std::vector<uint8_t> aad1, cipher1;
seal_at_current(aad1, cipher1); // sender counter 1 → 2
std::vector<uint8_t> forged_aad = aad1;
set_aad_seq(forged_aad, 0x0000FFFFFFFFFFFFULL); // bit-flip-style corruption
CHECK(receiver.open(cipher1.data(), cipher1.size(), forged_aad.data(), forged_aad.size(),
recovered.data(), recovered.size()) < 0);
// 3. The next legitimate frame (counter 2) must still decrypt. On the old code this
// returned "too old" because step 2 had poisoned recv_highest_.
std::vector<uint8_t> aad2, cipher2;
seal_at_current(aad2, cipher2); // sender counter 2 → 3
CHECK(receiver.open(cipher2.data(), cipher2.size(), aad2.data(), aad2.size(),
recovered.data(), recovered.size()) == static_cast<long>(plain.size()));
}
// Regression for the 16-bit seq wrap: with a full 64-bit wire counter, sealing/opening
// across the old u16 boundary (65,535 → 65,536) must keep decrypting. On the old code the
// nonce desynced at the wrap and every frame failed auth permanently.
static void test_seq_past_16bit_boundary() {
uint8_t key[crypto_aead_chacha20poly1305_ietf_KEYBYTES];
crypto_generichash(key, sizeof(key),
reinterpret_cast<const uint8_t*>("wrap-key"), 8, nullptr, 0);
SodiumMediaCrypto sender(key);
SodiumMediaCrypto receiver(key);
std::vector<uint8_t> plain(48, 0x6B);
std::vector<uint8_t> recovered(plain.size());
std::vector<uint8_t> cipher(plain.size() + crypto_aead_chacha20poly1305_ietf_ABYTES);
bool all_ok = true;
for (uint64_t i = 0; i < 70000; ++i) { // crosses 65,536
auto aad = make_aad_ssrc(0x1234, sender.peek_send_counter());
long s = sender.seal(plain.data(), plain.size(), aad.data(), aad.size(),
cipher.data(), cipher.size());
if (s < 0) { all_ok = false; break; }
long o = receiver.open(cipher.data(), static_cast<size_t>(s), aad.data(), aad.size(),
recovered.data(), recovered.size());
if (o != static_cast<long>(plain.size())) { all_ok = false; break; }
}
CHECK(all_ok);
}
int main() { int main() {
if (sodium_init() < 0) { if (sodium_init() < 0) {
std::printf("FAIL: sodium_init failed\n"); std::printf("FAIL: sodium_init failed\n");
@@ -262,6 +337,8 @@ int main() {
test_tamper_detection(); test_tamper_detection();
test_multiple_packets(); test_multiple_packets();
test_relay_interleaved_reseal(); test_relay_interleaved_reseal();
test_corrupted_seq_does_not_poison_window();
test_seq_past_16bit_boundary();
if (g_failures == 0) { if (g_failures == 0) {
std::printf("media_aead: all tests passed\n"); std::printf("media_aead: all tests passed\n");

View File

@@ -1,5 +1,5 @@
/* /*
* test_voice_frame — serialize/parse round-trips for the 14-byte UDP media header. * test_voice_frame — serialize/parse round-trips for the 20-byte UDP media header.
*/ */
#include <cassert> #include <cassert>
#include <cstdio> #include <cstdio>
@@ -24,7 +24,7 @@ static void test_header_round_trip() {
f.flags = kFlagMarker | kFlagFecPresent; f.flags = kFlagMarker | kFlagFecPresent;
f.codec = kCodecOpus; f.codec = kCodecOpus;
f.ssrc = 0xDEADBEEF; f.ssrc = 0xDEADBEEF;
f.seq = 0xAB12; f.seq = 0x0123456789ABCDEFULL; // full 64-bit range (protocol v2)
f.timestamp = 0x12345678; f.timestamp = 0x12345678;
uint8_t buf[kVoiceHeaderSize]; uint8_t buf[kVoiceHeaderSize];
@@ -97,18 +97,19 @@ static void test_parse_too_short() {
static void test_big_endian_layout() { static void test_big_endian_layout() {
VoiceFrame f; VoiceFrame f;
f.ssrc = 0x01020304; f.ssrc = 0x01020304;
f.seq = 0x0506; f.seq = 0x05060708090A0B0CULL;
f.timestamp = 0x0708090A; f.timestamp = 0x0D0E0F10;
uint8_t buf[kVoiceHeaderSize]; uint8_t buf[kVoiceHeaderSize];
serialize_header(f, buf); serialize_header(f, buf);
// ssrc at [4..7] // ssrc at [4..7]
CHECK(buf[4] == 0x01 && buf[5] == 0x02 && buf[6] == 0x03 && buf[7] == 0x04); CHECK(buf[4] == 0x01 && buf[5] == 0x02 && buf[6] == 0x03 && buf[7] == 0x04);
// seq at [8..9] // seq (u64) at [8..15]
CHECK(buf[8] == 0x05 && buf[9] == 0x06); CHECK(buf[8] == 0x05 && buf[9] == 0x06 && buf[10] == 0x07 && buf[11] == 0x08 &&
// timestamp at [10..13] buf[12] == 0x09 && buf[13] == 0x0A && buf[14] == 0x0B && buf[15] == 0x0C);
CHECK(buf[10] == 0x07 && buf[11] == 0x08 && buf[12] == 0x09 && buf[13] == 0x0A); // timestamp at [16..19]
CHECK(buf[16] == 0x0D && buf[17] == 0x0E && buf[18] == 0x0F && buf[19] == 0x10);
} }
int main() { int main() {