From 39b776bb3bd0b88f7172958ebde68e460ba11f79 Mon Sep 17 00:00:00 2001 From: Jonas Riedmann Date: Wed, 8 Apr 2026 20:26:23 +0200 Subject: [PATCH 1/7] fix(voice): fallback to raw payload when DAVE session not ready When dave.ready is False or the SSRC is not yet in ssrc_user_map, decrypt_rtp returned None causing the packet to be silently dropped in callback. Fall back to the nacl-decrypted raw_payload instead. Co-Authored-By: Claude Sonnet 4.6 --- discord/voice/receive/reader.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/discord/voice/receive/reader.py b/discord/voice/receive/reader.py index 7ec0300c66..7fc54e25a9 100644 --- a/discord/voice/receive/reader.py +++ b/discord/voice/receive/reader.py @@ -314,6 +314,14 @@ def decrypt_rtp(self, packet: RTPPacket) -> bytes: ) packet.decrypted_data = OPUS_SILENCE + if packet.decrypted_data is None: + # DAVE not ready or SSRC not yet mapped — fall back to raw decrypted payload + if packet.extended: + offset = packet.update_extended_header(raw_payload) + packet.decrypted_data = raw_payload[offset:] + else: + packet.decrypted_data = raw_payload + return packet.decrypted_data def decrypt_rtcp(self, packet: bytes) -> bytes: From d4cda0178e7418720c5e2f5615f6aab836780d4f Mon Sep 17 00:00:00 2001 From: Jonas Riedmann Date: Wed, 8 Apr 2026 21:09:35 +0200 Subject: [PATCH 2/7] fix(voice/receive): sink init + DAVE decrypt fallback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Call sink.init(client) in AudioReader.__init__ — was commented out, causing sink.vc = None and crash in opus.py assert - On DAVE decrypt failure, leave decrypted_data as None instead of OPUS_SILENCE so the raw_payload fallback is used (handles UnencryptedWhenPassthroughDisabled during MLS transition) Co-Authored-By: Claude Sonnet 4.6 --- discord/voice/receive/reader.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/discord/voice/receive/reader.py b/discord/voice/receive/reader.py index 7fc54e25a9..ce2843860b 100644 --- a/discord/voice/receive/reader.py +++ b/discord/voice/receive/reader.py @@ -86,7 +86,7 @@ def __init__( self.client: VoiceClient = client self.after: AfterCallback | None = after - # self.sink._client = client + self.sink.init(client) self.active: bool = False self.error: Exception | None = None @@ -312,7 +312,7 @@ def decrypt_rtp(self, packet: RTPPacket) -> bytes: _log.debug( "Ignoring exception while decoding DAVE packet", exc_info=exc ) - packet.decrypted_data = OPUS_SILENCE + # Leave decrypted_data as None so the fallback below uses raw_payload if packet.decrypted_data is None: # DAVE not ready or SSRC not yet mapped — fall back to raw decrypted payload From b280aeabce829dff9eb05b21cc86c1cea0ed0819 Mon Sep 17 00:00:00 2001 From: Jonas Riedmann Date: Thu, 9 Apr 2026 21:47:15 +0200 Subject: [PATCH 3/7] fix(dave): decrypt incoming DAVE-encrypted audio frames MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three fixes to make DAVE receive work end-to-end: 1. Extension-stripping (reader.py): For extended RTP packets the outer XChaCha decrypt returns [8B RTP extension values][DAVE frame]. davey.decrypt() must receive only the DAVE frame, so use raw_payload (= result[8:]) as dave_input for extended packets. Passing the full result caused AES-128-GCM auth-tag mismatch → NoValidCryptorFound. 2. Self-commit (gateway.py): After process_proposals() returns a CommitWelcome, immediately call process_commit(result.commit) to establish epoch-1 keys from our own commit. This prevents a mismatch when Discord later sends mls_welcome (op30) for a different group context instead of echoing our commit via mls_commit_transition (op29). 3. Skip op29/op30 when already ready (gateway.py): If the session is already ready from the self-commit, skip process_commit/process_welcome for incoming op29/op30 to avoid double-advancing or overwriting with mismatched key material. Still send transition_ready if needed. Result: DAVE decrypt FIRST SUCCESS confirmed (raw_len=138, out_len=126). Co-Authored-By: Claude Sonnet 4.6 --- discord/voice/gateway.py | 107 +++++++++++++++++++++++++------- discord/voice/receive/reader.py | 79 +++++++++++++++++++++-- 2 files changed, 158 insertions(+), 28 deletions(-) diff --git a/discord/voice/gateway.py b/discord/voice/gateway.py index d277917d2f..448447eb59 100644 --- a/discord/voice/gateway.py +++ b/discord/voice/gateway.py @@ -274,6 +274,7 @@ async def received_binary_message(self, msg: bytes) -> None: ) elif op == OpCodes.mls_proposals: op_type = msg[3] + epoch_before = state.dave_session.epoch result = state.dave_session.process_proposals( ( davey.ProposalsOperationType.append @@ -282,6 +283,11 @@ async def received_binary_message(self, msg: bytes) -> None: ), msg[4:], ) + _log.info( + "process_proposals done — epoch %s→%s ready=%s result=%s", + epoch_before, state.dave_session.epoch, + state.dave_session.ready, type(result).__name__, + ) if isinstance(result, davey.CommitWelcome): data = ( @@ -294,49 +300,102 @@ async def received_binary_message(self, msg: bytes) -> None: OpCodes.mls_commit_welcome, data, ) + # Apply our own commit immediately so we use the same epoch key + # material that Discord will forward to other participants. + # This avoids the mismatch when Discord sends us mls_welcome (op30) + # for a different group context. + try: + state.dave_session.process_commit(result.commit) + auth = state.dave_session.get_epoch_authenticator() + _log.info( + "Self-applied CommitWelcome.commit — epoch=%s ready=%s user_ids=%s privacy_code=%s epoch_auth=%s", + state.dave_session.epoch, + state.dave_session.ready, + state.dave_session.get_user_ids(), + state.dave_session.voice_privacy_code, + auth.hex() if auth else None, + ) + except Exception as exc: + _log.warning("Self-commit failed (non-fatal): %s", exc) _log.debug("Processed MLS proposals for current dave session: %r", result) elif op == OpCodes.mls_commit_transition: transt_id = struct.unpack_from(">H", msg, 3)[0] - try: - state.dave_session.process_commit(msg[5:]) + # If session is already ready (self-commit was applied), skip re-processing. + if state.dave_session.ready: + _log.info( + "mls_commit_transition (transition %s) skipped — session already ready epoch=%s", + transt_id, state.dave_session.epoch, + ) if transt_id != 0: state.dave_pending_transition = { "transition_id": transt_id, "protocol_version": state.dave_protocol_version, } + await self.send_dave_transition_ready(transt_id) + else: + try: + state.dave_session.process_commit(msg[5:]) + auth = state.dave_session.get_epoch_authenticator() + _log.info( + "MLS commit processed (transition %s) — dave.ready=%s epoch=%s user_ids=%s privacy_code=%s epoch_auth=%s", + transt_id, + state.dave_session.ready, + state.dave_session.epoch, + state.dave_session.get_user_ids(), + state.dave_session.voice_privacy_code, + auth.hex() if auth else None, + ) + if transt_id != 0: + state.dave_pending_transition = { + "transition_id": transt_id, + "protocol_version": state.dave_protocol_version, + } + _log.debug( + "Sending DAVE transition ready from MLS commit transition with data: %s", + state.dave_pending_transition, + ) + await self.send_dave_transition_ready(transt_id) + _log.debug("Processed MLS commit for transition %s", transt_id) + except Exception as exc: _log.debug( - "Sending DAVE transition ready from MLS commit transition with data: %s", - state.dave_pending_transition, + "An exception ocurred while processing a MLS commit, this should be safe to ignore: %s", + exc, ) - await self.send_dave_transition_ready(transt_id) - _log.debug("Processed MLS commit for transition %s", transt_id) - except Exception as exc: - _log.debug( - "An exception ocurred while processing a MLS commit, this should be safe to ignore: %s", - exc, - ) - await state.recover_dave_from_invalid_commit(transt_id) + await state.recover_dave_from_invalid_commit(transt_id) elif op == OpCodes.mls_welcome: transt_id = struct.unpack_from(">H", msg, 3)[0] - try: - state.dave_session.process_welcome(msg[5:]) + # If session is already ready (self-commit was applied), skip re-processing. + if state.dave_session.ready: + _log.info( + "mls_welcome (transition %s) skipped — session already ready epoch=%s", + transt_id, state.dave_session.epoch, + ) if transt_id != 0: state.dave_pending_transition = { "transition_id": transt_id, "protocol_version": state.dave_protocol_version, } + await self.send_dave_transition_ready(transt_id) + else: + try: + state.dave_session.process_welcome(msg[5:]) + if transt_id != 0: + state.dave_pending_transition = { + "transition_id": transt_id, + "protocol_version": state.dave_protocol_version, + } + _log.debug( + "Sending DAVE transition ready from MLS welcome with data: %s", + state.dave_pending_transition, + ) + await self.send_dave_transition_ready(transt_id) + _log.debug("Processed MLS welcome for transition %s", transt_id) + except Exception as exc: _log.debug( - "Sending DAVE transition ready from MLS welcome with data: %s", - state.dave_pending_transition, + "An exception ocurred while processing a MLS welcome, this should be safe to ignore: %s", + exc, ) - await self.send_dave_transition_ready(transt_id) - _log.debug("Processed MLS welcome for transition %s", transt_id) - except Exception as exc: - _log.debug( - "An exception ocurred while processing a MLS welcome, this should be safe to ignore: %s", - exc, - ) - await state.recover_dave_from_invalid_commit(transt_id) + await state.recover_dave_from_invalid_commit(transt_id) async def ready(self, data: dict[str, Any]) -> None: state = self.state diff --git a/discord/voice/receive/reader.py b/discord/voice/receive/reader.py index ce2843860b..1d3893c431 100644 --- a/discord/voice/receive/reader.py +++ b/discord/voice/receive/reader.py @@ -287,12 +287,59 @@ def _make_box(self, secret_key: bytes) -> EncryptionBox: return data""" + # Per-SSRC success/failure counters for DAVE decrypt diagnostics + _dave_success: dict[int, int] = {} + _dave_failure: dict[int, int] = {} + # Track which generations we've seen per SSRC to avoid log spam + _dave_seen_generations: dict[int, set] = {} + + @staticmethod + def _parse_dave_generation(data: bytes) -> int: + """Extract key generation (epoch) from DAVE frame supplemental data. + + DAVE frame layout (from end): + [...ciphertext][auth_tag(8B)][nonce(LEB128)][supp_size(1B)][magic(0xFAFA, 2B)] + supp_size = total bytes of block including supp_size+magic. + generation = (truncatedNonce >> 24) & 0xFF + """ + if len(data) < 12: # minimum: auth_tag(8)+nonce(1)+supp_size(1)+magic(2) + return -1 + if data[-2:] != b'\xfa\xfa': + return -1 + supp_size = data[-3] + if supp_size < 11 or supp_size > len(data): + return -1 + block_start = len(data) - supp_size + nonce_pos = block_start + 8 # skip auth_tag (8B) + nonce_end = len(data) - 3 # supp_size byte position + # Decode LEB128 nonce + nonce = 0 + shift = 0 + for i in range(nonce_pos, nonce_end): + b = data[i] + nonce |= (b & 0x7F) << shift + shift += 7 + if not (b & 0x80): + break + return (nonce >> 24) & 0xFF + def decrypt_rtp(self, packet: RTPPacket) -> bytes: state = self.client._connection dave = state.dave_session raw_payload = self._decryptor_rtp(packet) + # For extended RTP packets (which Discord always sends for audio), + # _decryptor_rtp returns result[8:] which already strips the 8-byte + # RTP extension values that precede the DAVE frame. davey.decrypt() + # must receive ONLY the DAVE frame (no extension values prepended). + # For non-extended packets result[8:] is wrong so fall back to the + # full outer-decrypted frame. + if packet.extended: + dave_input = raw_payload # result[8:] = DAVE frame only + else: + dave_input = getattr(packet, '_outer_decrypted', raw_payload) + if dave is not None and dave.ready: uid = state.ssrc_user_map.get(packet.ssrc) if uid: @@ -300,18 +347,37 @@ def decrypt_rtp(self, packet: RTPPacket) -> bytes: decrypted_audio = dave.decrypt( uid, davey.MediaType.audio, - raw_payload, + dave_input, ) + self._dave_success[packet.ssrc] = self._dave_success.get(packet.ssrc, 0) + 1 + total = self._dave_success.get(packet.ssrc, 0) + if total == 1: + _log.info( + "DAVE decrypt FIRST SUCCESS ssrc=%s uid=%s raw_len=%d out_len=%d raw_head=%s", + packet.ssrc, uid, len(raw_payload), len(decrypted_audio), + raw_payload[:16].hex(), + ) + if packet.extended: offset = packet.update_extended_header(decrypted_audio) packet.decrypted_data = decrypted_audio[offset:] else: packet.decrypted_data = decrypted_audio except Exception as exc: - _log.debug( - "Ignoring exception while decoding DAVE packet", exc_info=exc - ) + fail_count = self._dave_failure.get(packet.ssrc, 0) + 1 + self._dave_failure[packet.ssrc] = fail_count + gen = self._parse_dave_generation(dave_input) + seen = self._dave_seen_generations.setdefault(packet.ssrc, set()) + # Log on first 3 failures per SSRC, then only when a NEW generation appears + if fail_count <= 3 or gen not in seen: + _log.warning( + "DAVE decrypt FAIL #%d ssrc=%s uid=%s dave_input_len=%d " + "dave_head=%s frame_gen=%s bot_epoch=%s err=%s", + fail_count, packet.ssrc, uid, len(dave_input), + dave_input[:8].hex(), gen, dave.epoch, exc, + ) + seen.add(gen) # Leave decrypted_data as None so the fallback below uses raw_payload if packet.decrypted_data is None: @@ -435,6 +501,11 @@ def _decrypt_rtp_aead_xchacha20_poly1305_rtpsize(self, packet: RTPPacket) -> byt if packet.extended: packet.update_extended_header(result) + # Store the full outer-decrypted result so that DAVE decrypt can + # receive the complete DAVE frame (including its 8-byte header). + # The caller strips result[8:] for the non-DAVE fallback path. + packet._outer_decrypted = result + return result[8:] def _decrypt_rtcp_aead_xchacha20_poly1305_rtpsize(self, data: bytes) -> bytes: From c6f1635ceb8859eb32824e7f213376c35d3caef9 Mon Sep 17 00:00:00 2001 From: Jonas Riedmann Date: Thu, 9 Apr 2026 22:30:54 +0200 Subject: [PATCH 4/7] fix(dave): correct Opus output handling after DAVE decrypt Three fixes in PacketDecryptor.decrypt_rtp: 1. Do not call update_extended_header() on DAVE-decrypted audio. davey.decrypt() returns pure Opus bytes. The extension header was already parsed during outer XChaCha decryption. Calling update_extended_header() on the Opus output misinterprets the Opus TOC byte as extension length, strips bytes from the frame, and causes OpusError("corrupted stream") in the decoder thread. 2. When DAVE decrypt raises an exception, fall back to Opus silence (b'\xf8\xff\xfe') instead of leaving decrypted_data as None. The previous None-based fallback passed DAVE-ciphertext garbage to the Opus decoder (in PCM-mode sinks), killing the router thread. 3. When DAVE is active but the session is not yet ready (MLS handshake in progress), also use Opus silence as fallback. Passing cipher- text to the Opus decoder during the handshake window caused the same crash. Confirmed working: WavSink test produces correct, audible audio. Co-Authored-By: Claude Sonnet 4.6 --- discord/voice/receive/reader.py | 30 +++++++++++++++++++----------- 1 file changed, 19 insertions(+), 11 deletions(-) diff --git a/discord/voice/receive/reader.py b/discord/voice/receive/reader.py index 1d3893c431..6d8713e324 100644 --- a/discord/voice/receive/reader.py +++ b/discord/voice/receive/reader.py @@ -359,11 +359,11 @@ def decrypt_rtp(self, packet: RTPPacket) -> bytes: raw_payload[:16].hex(), ) - if packet.extended: - offset = packet.update_extended_header(decrypted_audio) - packet.decrypted_data = decrypted_audio[offset:] - else: - packet.decrypted_data = decrypted_audio + # DAVE output is pure Opus — extension header was already + # stripped during outer XChaCha decryption. Do NOT call + # update_extended_header here; it would misinterpret Opus + # bytes as extension values and corrupt the frame. + packet.decrypted_data = decrypted_audio except Exception as exc: fail_count = self._dave_failure.get(packet.ssrc, 0) + 1 self._dave_failure[packet.ssrc] = fail_count @@ -378,15 +378,23 @@ def decrypt_rtp(self, packet: RTPPacket) -> bytes: dave_input[:8].hex(), gen, dave.epoch, exc, ) seen.add(gen) - # Leave decrypted_data as None so the fallback below uses raw_payload + # DAVE decrypt failed — use Opus silence so the decoder doesn't + # crash with "corrupted stream" and kill the router thread. + packet.decrypted_data = b'\xf8\xff\xfe' if packet.decrypted_data is None: - # DAVE not ready or SSRC not yet mapped — fall back to raw decrypted payload - if packet.extended: - offset = packet.update_extended_header(raw_payload) - packet.decrypted_data = raw_payload[offset:] + if dave is None: + # Non-DAVE mode: outer-decrypted bytes ARE the Opus payload. + if packet.extended: + offset = packet.update_extended_header(raw_payload) + packet.decrypted_data = raw_payload[offset:] + else: + packet.decrypted_data = raw_payload else: - packet.decrypted_data = raw_payload + # DAVE mode but session not ready yet / SSRC not mapped. + # Use Opus silence to avoid crashing the Opus decoder with + # DAVE-ciphertext garbage during MLS handshake window. + packet.decrypted_data = b'\xf8\xff\xfe' return packet.decrypted_data From 6e71bfffb520f77948ff28544949925a55767ee7 Mon Sep 17 00:00:00 2001 From: Jonas Riedmann Date: Mon, 13 Apr 2026 01:22:45 +0200 Subject: [PATCH 5/7] fix(dave): handle passthrough frames and improve decrypt diagnostics MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Discord sporadically sends passthrough frames (~5% of all frames) even when DAVE is active. These frames are rejected by davey with UnencryptedWhenPassthroughDisabled because passthrough mode is disabled. Root cause (reverse-engineered from wire data): Passthrough frames have the format: [raw_opus][dave_supp_block(supp_size B)][rtp_padding] The DAVE supplemental block ends with supp_size(1B) + 0xFAFA(2B); supp_size counts the entire block including itself and the magic bytes. RTP padding (RFC 3550): last byte = N, strip N bytes from end. Fix in decrypt_rtp: - Strip RTP padding (if packet.padding), then strip the DAVE block - Use the recovered raw Opus directly instead of silence - Placeholder frames (all-0xFF, uniform bytes, no 0xFAFA) → silence Additional improvements: - Dynamic extension offset: use update_extended_header() return value instead of hardcoded result[8:] in _decrypt_rtp_aead_xchacha20_* - Richer diagnostic logging: consec failures, time since last success, RTP sequence number, ext_hdr bytes, outer_head, dave_tail Result: audio is clear without dropouts, router thread never crashes. Co-Authored-By: Claude Sonnet 4.6 --- discord/voice/receive/reader.py | 90 +++++++++++++++++++++++++++------ 1 file changed, 75 insertions(+), 15 deletions(-) diff --git a/discord/voice/receive/reader.py b/discord/voice/receive/reader.py index 6d8713e324..bcf8e67abd 100644 --- a/discord/voice/receive/reader.py +++ b/discord/voice/receive/reader.py @@ -290,6 +290,8 @@ def _make_box(self, secret_key: bytes) -> EncryptionBox: # Per-SSRC success/failure counters for DAVE decrypt diagnostics _dave_success: dict[int, int] = {} _dave_failure: dict[int, int] = {} + _dave_consecutive_failures: dict[int, int] = {} + _dave_last_success_time: dict[int, float] = {} # Track which generations we've seen per SSRC to avoid log spam _dave_seen_generations: dict[int, set] = {} @@ -352,12 +354,22 @@ def decrypt_rtp(self, packet: RTPPacket) -> bytes: self._dave_success[packet.ssrc] = self._dave_success.get(packet.ssrc, 0) + 1 total = self._dave_success.get(packet.ssrc, 0) + prev_consec_fail = self._dave_consecutive_failures.get(packet.ssrc, 0) + self._dave_consecutive_failures[packet.ssrc] = 0 + now = time.perf_counter() + self._dave_last_success_time[packet.ssrc] = now + if total == 1: _log.info( "DAVE decrypt FIRST SUCCESS ssrc=%s uid=%s raw_len=%d out_len=%d raw_head=%s", packet.ssrc, uid, len(raw_payload), len(decrypted_audio), raw_payload[:16].hex(), ) + elif prev_consec_fail > 0: + _log.info( + "DAVE decrypt RECOVERED ssrc=%s uid=%s seq=%s after %d consec failures", + packet.ssrc, uid, getattr(packet, 'sequence', '?'), prev_consec_fail, + ) # DAVE output is pure Opus — extension header was already # stripped during outer XChaCha decryption. Do NOT call @@ -367,20 +379,65 @@ def decrypt_rtp(self, packet: RTPPacket) -> bytes: except Exception as exc: fail_count = self._dave_failure.get(packet.ssrc, 0) + 1 self._dave_failure[packet.ssrc] = fail_count + consec = self._dave_consecutive_failures.get(packet.ssrc, 0) + 1 + self._dave_consecutive_failures[packet.ssrc] = consec gen = self._parse_dave_generation(dave_input) seen = self._dave_seen_generations.setdefault(packet.ssrc, set()) - # Log on first 3 failures per SSRC, then only when a NEW generation appears - if fail_count <= 3 or gen not in seen: + now = time.perf_counter() + last_ok = self._dave_last_success_time.get(packet.ssrc) + since_ok = f"{now - last_ok:.3f}s" if last_ok is not None else "never" + exc_type = type(exc).__name__ + # Log first failure in a new run, first 3 ever, or on new generation + if consec == 1 or fail_count <= 3 or gen not in seen: + # For UnencryptedWhenPassthroughDisabled: log full outer result + # and extension header so we can determine the actual Opus offset. + outer = getattr(packet, '_outer_decrypted', None) + ext_hdr_hex = packet.header[-4:].hex() if hasattr(packet, 'header') else '?' + outer_head = outer[:16].hex() if outer else '?' _log.warning( - "DAVE decrypt FAIL #%d ssrc=%s uid=%s dave_input_len=%d " - "dave_head=%s frame_gen=%s bot_epoch=%s err=%s", - fail_count, packet.ssrc, uid, len(dave_input), - dave_input[:8].hex(), gen, dave.epoch, exc, + "DAVE decrypt FAIL #%d (consec=%d) ssrc=%s uid=%s seq=%s " + "since_last_ok=%s dave_input_len=%d " + "dave_head=%s dave_tail=%s frame_gen=%s bot_epoch=%s err=%s(%s) " + "ext_hdr=%s outer_head=%s raw_payload_head=%s raw_payload_tail=%s", + fail_count, consec, packet.ssrc, uid, + getattr(packet, 'sequence', '?'), + since_ok, len(dave_input), + dave_input[:8].hex(), dave_input[-8:].hex(), + gen, dave.epoch, exc_type, exc, + ext_hdr_hex, outer_head, raw_payload[:16].hex(), + raw_payload[-8:].hex(), ) seen.add(gen) - # DAVE decrypt failed — use Opus silence so the decoder doesn't - # crash with "corrupted stream" and kill the router thread. - packet.decrypted_data = b'\xf8\xff\xfe' + # UnencryptedWhenPassthroughDisabled: Discord sent a passthrough + # frame. These frames have the format: + # [raw_opus][dave_supp_block(supp_size bytes)][rtp_padding] + # dave_supp_block ends with supp_size(1B) + 0xFAFA(2B); supp_size + # counts the entire block including itself and the magic bytes. + # RTP padding (if set): last byte = N, strip N bytes from end. + # Strip padding then the DAVE block to recover raw Opus. + if "UnencryptedWhenPassthroughDisabled" in str(exc): + # Passthrough frames: [raw_opus][dave_supp_block][rtp_padding] + # Strip RTP padding, then DAVE block (ends with supp_size+fafa). + # If fafa not found → not a valid passthrough frame → silence. + opus_data = raw_payload + if packet.padding and opus_data: + pad_n = opus_data[-1] + if 0 < pad_n < len(opus_data): + opus_data = opus_data[:-pad_n] + if len(opus_data) >= 3 and opus_data[-2:] == b'\xfa\xfa': + supp_size = opus_data[-3] + if 3 <= supp_size < len(opus_data): + opus_data = opus_data[:-supp_size] + packet.decrypted_data = opus_data if len(opus_data) >= 3 else b'\xf8\xff\xfe' + else: + packet.decrypted_data = b'\xf8\xff\xfe' + else: + # No DAVE trailer (e.g. all-0xff placeholder frame) → silence + packet.decrypted_data = b'\xf8\xff\xfe' + else: + # Real decrypt failure — use Opus silence so the decoder + # doesn't crash with "corrupted stream". + packet.decrypted_data = b'\xf8\xff\xfe' if packet.decrypted_data is None: if dave is None: @@ -506,15 +563,18 @@ def _decrypt_rtp_aead_xchacha20_poly1305_rtpsize(self, packet: RTPPacket) -> byt _log.error("Critical error at AEAD: %s", exc) raise CryptoError(exc) + # update_extended_header returns the actual payload offset into result. + # For Discord DAVE frames the extension has length=2 (8 bytes) → offset=8. + # For passthrough/unencrypted frames the extension has length=1 (4 bytes) + # → offset=4. Hardcoding result[8:] would strip 4 bytes too many for + # passthrough frames and hand invalid bytes to davey / the Opus decoder. if packet.extended: - packet.update_extended_header(result) + offset = packet.update_extended_header(result) + else: + offset = 0 - # Store the full outer-decrypted result so that DAVE decrypt can - # receive the complete DAVE frame (including its 8-byte header). - # The caller strips result[8:] for the non-DAVE fallback path. packet._outer_decrypted = result - - return result[8:] + return result[offset:] def _decrypt_rtcp_aead_xchacha20_poly1305_rtpsize(self, data: bytes) -> bytes: _log.debug("Decrypting RTCP AEAD XChaCha20 Poly1305 RTPSize") From e714c916171e4fc22c0ffb26c4b0a628e9b28a96 Mon Sep 17 00:00:00 2001 From: Jonas Riedmann Date: Mon, 13 Apr 2026 01:25:57 +0200 Subject: [PATCH 6/7] refactor(dave): clean up decrypt logging and remove diagnostic code Remove verbose per-frame hex dumps (dave_tail, outer_head, ext_hdr, raw_payload_head/tail, since_last_ok) that were added for debugging the passthrough frame format. Keep only the essential log lines: - DEBUG when DAVE decryption first becomes active for an SSRC - INFO when decryption recovers after a burst of failures - WARNING on the first failure in a burst or a new key generation Also remove the unused _dave_failure and _dave_last_success_time counters, replace bare b'\xf8\xff\xfe' literals with OPUS_SILENCE, and drop the per-request debug log from _decrypt_rtp_aead_xchacha20_*. Co-Authored-By: Claude Sonnet 4.6 --- discord/voice/receive/reader.py | 129 +++++++++++--------------------- 1 file changed, 44 insertions(+), 85 deletions(-) diff --git a/discord/voice/receive/reader.py b/discord/voice/receive/reader.py index bcf8e67abd..ba207bae17 100644 --- a/discord/voice/receive/reader.py +++ b/discord/voice/receive/reader.py @@ -287,24 +287,20 @@ def _make_box(self, secret_key: bytes) -> EncryptionBox: return data""" - # Per-SSRC success/failure counters for DAVE decrypt diagnostics + # Per-SSRC counters used to suppress repetitive log lines. _dave_success: dict[int, int] = {} - _dave_failure: dict[int, int] = {} _dave_consecutive_failures: dict[int, int] = {} - _dave_last_success_time: dict[int, float] = {} - # Track which generations we've seen per SSRC to avoid log spam _dave_seen_generations: dict[int, set] = {} @staticmethod def _parse_dave_generation(data: bytes) -> int: - """Extract key generation (epoch) from DAVE frame supplemental data. + """Return the key generation encoded in a DAVE supplemental block, or -1. - DAVE frame layout (from end): - [...ciphertext][auth_tag(8B)][nonce(LEB128)][supp_size(1B)][magic(0xFAFA, 2B)] - supp_size = total bytes of block including supp_size+magic. - generation = (truncatedNonce >> 24) & 0xFF + DAVE frame layout (from end of payload): + [...ciphertext][auth_tag(8B)][nonce(LEB128)][supp_size(1B)][0xFAFA(2B)] + supp_size counts the entire trailing block including itself and the magic. """ - if len(data) < 12: # minimum: auth_tag(8)+nonce(1)+supp_size(1)+magic(2) + if len(data) < 12: return -1 if data[-2:] != b'\xfa\xfa': return -1 @@ -313,8 +309,7 @@ def _parse_dave_generation(data: bytes) -> int: return -1 block_start = len(data) - supp_size nonce_pos = block_start + 8 # skip auth_tag (8B) - nonce_end = len(data) - 3 # supp_size byte position - # Decode LEB128 nonce + nonce_end = len(data) - 3 # position of supp_size byte nonce = 0 shift = 0 for i in range(nonce_pos, nonce_end): @@ -332,13 +327,11 @@ def decrypt_rtp(self, packet: RTPPacket) -> bytes: raw_payload = self._decryptor_rtp(packet) # For extended RTP packets (which Discord always sends for audio), - # _decryptor_rtp returns result[8:] which already strips the 8-byte - # RTP extension values that precede the DAVE frame. davey.decrypt() - # must receive ONLY the DAVE frame (no extension values prepended). - # For non-extended packets result[8:] is wrong so fall back to the - # full outer-decrypted frame. + # _decryptor_rtp already strips the RTP extension values so that + # davey.decrypt() receives only the DAVE frame. For non-extended + # packets fall back to the full outer-decrypted buffer. if packet.extended: - dave_input = raw_payload # result[8:] = DAVE frame only + dave_input = raw_payload else: dave_input = getattr(packet, '_outer_decrypted', raw_payload) @@ -352,73 +345,47 @@ def decrypt_rtp(self, packet: RTPPacket) -> bytes: dave_input, ) - self._dave_success[packet.ssrc] = self._dave_success.get(packet.ssrc, 0) + 1 - total = self._dave_success.get(packet.ssrc, 0) - prev_consec_fail = self._dave_consecutive_failures.get(packet.ssrc, 0) + success_count = self._dave_success.get(packet.ssrc, 0) + 1 + self._dave_success[packet.ssrc] = success_count + prev_fails = self._dave_consecutive_failures.get(packet.ssrc, 0) self._dave_consecutive_failures[packet.ssrc] = 0 - now = time.perf_counter() - self._dave_last_success_time[packet.ssrc] = now - if total == 1: + if success_count == 1: + _log.debug("DAVE decrypt active ssrc=%s uid=%s", packet.ssrc, uid) + elif prev_fails > 0: _log.info( - "DAVE decrypt FIRST SUCCESS ssrc=%s uid=%s raw_len=%d out_len=%d raw_head=%s", - packet.ssrc, uid, len(raw_payload), len(decrypted_audio), - raw_payload[:16].hex(), - ) - elif prev_consec_fail > 0: - _log.info( - "DAVE decrypt RECOVERED ssrc=%s uid=%s seq=%s after %d consec failures", - packet.ssrc, uid, getattr(packet, 'sequence', '?'), prev_consec_fail, + "DAVE decrypt recovered ssrc=%s uid=%s after %d frame(s)", + packet.ssrc, uid, prev_fails, ) - # DAVE output is pure Opus — extension header was already - # stripped during outer XChaCha decryption. Do NOT call - # update_extended_header here; it would misinterpret Opus - # bytes as extension values and corrupt the frame. + # DAVE output is pure Opus — do NOT call update_extended_header; + # it would misinterpret Opus bytes as RTP extension values. packet.decrypted_data = decrypted_audio + except Exception as exc: - fail_count = self._dave_failure.get(packet.ssrc, 0) + 1 - self._dave_failure[packet.ssrc] = fail_count consec = self._dave_consecutive_failures.get(packet.ssrc, 0) + 1 self._dave_consecutive_failures[packet.ssrc] = consec gen = self._parse_dave_generation(dave_input) seen = self._dave_seen_generations.setdefault(packet.ssrc, set()) - now = time.perf_counter() - last_ok = self._dave_last_success_time.get(packet.ssrc) - since_ok = f"{now - last_ok:.3f}s" if last_ok is not None else "never" - exc_type = type(exc).__name__ - # Log first failure in a new run, first 3 ever, or on new generation - if consec == 1 or fail_count <= 3 or gen not in seen: - # For UnencryptedWhenPassthroughDisabled: log full outer result - # and extension header so we can determine the actual Opus offset. - outer = getattr(packet, '_outer_decrypted', None) - ext_hdr_hex = packet.header[-4:].hex() if hasattr(packet, 'header') else '?' - outer_head = outer[:16].hex() if outer else '?' + + # Log on the first failure in a burst or when a new generation appears. + if consec == 1 or gen not in seen: _log.warning( - "DAVE decrypt FAIL #%d (consec=%d) ssrc=%s uid=%s seq=%s " - "since_last_ok=%s dave_input_len=%d " - "dave_head=%s dave_tail=%s frame_gen=%s bot_epoch=%s err=%s(%s) " - "ext_hdr=%s outer_head=%s raw_payload_head=%s raw_payload_tail=%s", - fail_count, consec, packet.ssrc, uid, - getattr(packet, 'sequence', '?'), - since_ok, len(dave_input), - dave_input[:8].hex(), dave_input[-8:].hex(), - gen, dave.epoch, exc_type, exc, - ext_hdr_hex, outer_head, raw_payload[:16].hex(), - raw_payload[-8:].hex(), + "DAVE decrypt failed ssrc=%s uid=%s frame_gen=%s epoch=%s err=%s", + packet.ssrc, uid, gen, dave.epoch, type(exc).__name__, ) seen.add(gen) - # UnencryptedWhenPassthroughDisabled: Discord sent a passthrough - # frame. These frames have the format: - # [raw_opus][dave_supp_block(supp_size bytes)][rtp_padding] - # dave_supp_block ends with supp_size(1B) + 0xFAFA(2B); supp_size - # counts the entire block including itself and the magic bytes. - # RTP padding (if set): last byte = N, strip N bytes from end. - # Strip padding then the DAVE block to recover raw Opus. + if "UnencryptedWhenPassthroughDisabled" in str(exc): - # Passthrough frames: [raw_opus][dave_supp_block][rtp_padding] - # Strip RTP padding, then DAVE block (ends with supp_size+fafa). - # If fafa not found → not a valid passthrough frame → silence. + # Discord sends passthrough (unencrypted) frames even while DAVE + # is active. These carry raw Opus wrapped in a small DAVE + # supplemental block with optional RTP padding appended: + # + # [raw_opus][supp_block(supp_size B)][rtp_padding] + # + # supp_block ends with supp_size(1B) + 0xFAFA(2B); supp_size + # counts the whole block including itself and the magic bytes. + # RTP padding (RFC 3550): last byte = N, strip N bytes from end. opus_data = raw_payload if packet.padding and opus_data: pad_n = opus_data[-1] @@ -428,16 +395,13 @@ def decrypt_rtp(self, packet: RTPPacket) -> bytes: supp_size = opus_data[-3] if 3 <= supp_size < len(opus_data): opus_data = opus_data[:-supp_size] - packet.decrypted_data = opus_data if len(opus_data) >= 3 else b'\xf8\xff\xfe' + packet.decrypted_data = opus_data if len(opus_data) >= 3 else OPUS_SILENCE else: - packet.decrypted_data = b'\xf8\xff\xfe' + packet.decrypted_data = OPUS_SILENCE else: - # No DAVE trailer (e.g. all-0xff placeholder frame) → silence - packet.decrypted_data = b'\xf8\xff\xfe' + packet.decrypted_data = OPUS_SILENCE else: - # Real decrypt failure — use Opus silence so the decoder - # doesn't crash with "corrupted stream". - packet.decrypted_data = b'\xf8\xff\xfe' + packet.decrypted_data = OPUS_SILENCE if packet.decrypted_data is None: if dave is None: @@ -448,10 +412,9 @@ def decrypt_rtp(self, packet: RTPPacket) -> bytes: else: packet.decrypted_data = raw_payload else: - # DAVE mode but session not ready yet / SSRC not mapped. - # Use Opus silence to avoid crashing the Opus decoder with - # DAVE-ciphertext garbage during MLS handshake window. - packet.decrypted_data = b'\xf8\xff\xfe' + # DAVE session not ready yet or SSRC not yet mapped — use Opus + # silence to avoid feeding ciphertext to the Opus decoder. + packet.decrypted_data = OPUS_SILENCE return packet.decrypted_data @@ -544,10 +507,6 @@ def _decrypt_rtcp_xsalsa20_poly1305_lite(self, data: bytes) -> bytes: return header + result def _decrypt_rtp_aead_xchacha20_poly1305_rtpsize(self, packet: RTPPacket) -> bytes: - _log.debug( - "Decrypting RTP AEAD XChaCha20 Poly1305 RTPSize, has decrypted data?: %s", - packet.decrypted_data is not None, - ) packet.adjust_rtpsize() nonce = packet.nonce + b"\x00" * 20 From 65d506852a66e89d8f450985d82ce97418d4555e Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Sun, 12 Apr 2026 23:48:40 +0000 Subject: [PATCH 7/7] style(pre-commit): auto fixes from pre-commit.com hooks --- discord/voice/gateway.py | 12 ++++++++---- discord/voice/receive/reader.py | 26 ++++++++++++++++++-------- 2 files changed, 26 insertions(+), 12 deletions(-) diff --git a/discord/voice/gateway.py b/discord/voice/gateway.py index 448447eb59..ce782c7150 100644 --- a/discord/voice/gateway.py +++ b/discord/voice/gateway.py @@ -285,8 +285,10 @@ async def received_binary_message(self, msg: bytes) -> None: ) _log.info( "process_proposals done — epoch %s→%s ready=%s result=%s", - epoch_before, state.dave_session.epoch, - state.dave_session.ready, type(result).__name__, + epoch_before, + state.dave_session.epoch, + state.dave_session.ready, + type(result).__name__, ) if isinstance(result, davey.CommitWelcome): @@ -324,7 +326,8 @@ async def received_binary_message(self, msg: bytes) -> None: if state.dave_session.ready: _log.info( "mls_commit_transition (transition %s) skipped — session already ready epoch=%s", - transt_id, state.dave_session.epoch, + transt_id, + state.dave_session.epoch, ) if transt_id != 0: state.dave_pending_transition = { @@ -368,7 +371,8 @@ async def received_binary_message(self, msg: bytes) -> None: if state.dave_session.ready: _log.info( "mls_welcome (transition %s) skipped — session already ready epoch=%s", - transt_id, state.dave_session.epoch, + transt_id, + state.dave_session.epoch, ) if transt_id != 0: state.dave_pending_transition = { diff --git a/discord/voice/receive/reader.py b/discord/voice/receive/reader.py index ba207bae17..bd4fa4cc0c 100644 --- a/discord/voice/receive/reader.py +++ b/discord/voice/receive/reader.py @@ -302,14 +302,14 @@ def _parse_dave_generation(data: bytes) -> int: """ if len(data) < 12: return -1 - if data[-2:] != b'\xfa\xfa': + if data[-2:] != b"\xfa\xfa": return -1 supp_size = data[-3] if supp_size < 11 or supp_size > len(data): return -1 block_start = len(data) - supp_size nonce_pos = block_start + 8 # skip auth_tag (8B) - nonce_end = len(data) - 3 # position of supp_size byte + nonce_end = len(data) - 3 # position of supp_size byte nonce = 0 shift = 0 for i in range(nonce_pos, nonce_end): @@ -333,7 +333,7 @@ def decrypt_rtp(self, packet: RTPPacket) -> bytes: if packet.extended: dave_input = raw_payload else: - dave_input = getattr(packet, '_outer_decrypted', raw_payload) + dave_input = getattr(packet, "_outer_decrypted", raw_payload) if dave is not None and dave.ready: uid = state.ssrc_user_map.get(packet.ssrc) @@ -351,11 +351,15 @@ def decrypt_rtp(self, packet: RTPPacket) -> bytes: self._dave_consecutive_failures[packet.ssrc] = 0 if success_count == 1: - _log.debug("DAVE decrypt active ssrc=%s uid=%s", packet.ssrc, uid) + _log.debug( + "DAVE decrypt active ssrc=%s uid=%s", packet.ssrc, uid + ) elif prev_fails > 0: _log.info( "DAVE decrypt recovered ssrc=%s uid=%s after %d frame(s)", - packet.ssrc, uid, prev_fails, + packet.ssrc, + uid, + prev_fails, ) # DAVE output is pure Opus — do NOT call update_extended_header; @@ -372,7 +376,11 @@ def decrypt_rtp(self, packet: RTPPacket) -> bytes: if consec == 1 or gen not in seen: _log.warning( "DAVE decrypt failed ssrc=%s uid=%s frame_gen=%s epoch=%s err=%s", - packet.ssrc, uid, gen, dave.epoch, type(exc).__name__, + packet.ssrc, + uid, + gen, + dave.epoch, + type(exc).__name__, ) seen.add(gen) @@ -391,11 +399,13 @@ def decrypt_rtp(self, packet: RTPPacket) -> bytes: pad_n = opus_data[-1] if 0 < pad_n < len(opus_data): opus_data = opus_data[:-pad_n] - if len(opus_data) >= 3 and opus_data[-2:] == b'\xfa\xfa': + if len(opus_data) >= 3 and opus_data[-2:] == b"\xfa\xfa": supp_size = opus_data[-3] if 3 <= supp_size < len(opus_data): opus_data = opus_data[:-supp_size] - packet.decrypted_data = opus_data if len(opus_data) >= 3 else OPUS_SILENCE + packet.decrypted_data = ( + opus_data if len(opus_data) >= 3 else OPUS_SILENCE + ) else: packet.decrypted_data = OPUS_SILENCE else: