Skip to content

fix(dave): decrypt incoming DAVE-encrypted audio and handle passthrough frames#3202

Closed
Phydra11 wants to merge 7 commits intoPycord-Development:masterfrom
Phydra11:dave-receive-fix
Closed

fix(dave): decrypt incoming DAVE-encrypted audio and handle passthrough frames#3202
Phydra11 wants to merge 7 commits intoPycord-Development:masterfrom
Phydra11:dave-receive-fix

Conversation

@Phydra11
Copy link
Copy Markdown

Summary

Discord added mandatory DAVE (Discord Audio Video Encryption) E2EE for voice channels in March 2026. Without this fix, bots using py-cord cannot receive or decode any voice audio because all incoming frames are DAVE-encrypted.

This PR fixes the voice receive pipeline so that incoming DAVE-encrypted audio frames are correctly decrypted using the davey library (Python bindings for Discord's MLS-based encryption).

Changes in discord/voice/receive/reader.pyPacketDecryptor:

  • Added DAVE decryption layer in decrypt_rtp() using davey.DaveSession.decrypt()
  • Fixed _decrypt_rtp_aead_xchacha20_poly1305_rtpsize() to use the dynamic offset returned by update_extended_header() instead of a hardcoded result[8:]
  • Added handling for passthrough frames: Discord sends ~5% of frames unencrypted even when DAVE is active (these carry a DAVE supplemental block and RTP padding appended after the raw Opus payload). Both trailers are stripped to recover valid Opus.
  • Added Opus DTX silence fallback (\xf8\xff\xfe) for decrypt errors instead of passing cipher bytes to the Opus decoder, which would crash the router thread
  • After DAVE decrypt, packet.decrypted_data is set directly — update_extended_header() is NOT called on the decrypted output (the RTP extension was already handled in the XChaCha layer)

Passthrough frame format (reverse-engineered from live Discord traffic):

[raw_opus][dave_supp_block (supp_size bytes)][rtp_padding]

  • RTP padding (RFC 3550): last byte = N, strip N bytes from end
  • DAVE supplemental block ends with supp_size (1 byte) + 0xFAFA (2 bytes); supp_size counts the whole block (typically 12 bytes)
  • Valid Opus = remaining bytes after stripping both trailers

Tested: Real Discord voice channel with DAVE E2EE active, 883 packets over 17.7 seconds, zero crashes, clean audio output verified via WavSink.

AI was used to assist with development of this PR. I understand fully what the code does.

Information

  • This PR fixes an issue.
  • This PR adds something new (e.g. new method or parameters).
  • This PR is a breaking change (e.g. methods or parameters removed/renamed).
  • This PR is not a code change (e.g. documentation, README, typehinting, examples, ...).

Checklist

  • I have searched the open pull requests for duplicates.
  • If code changes were made then they have been tested.
    • I have updated the documentation to reflect the changes.
  • If type: ignore comments were used, a comment is also left explaining why.
  • I have updated the changelog to include these changes.
  • AI Usage has been disclosed.
    • If AI has been used, I understand fully what the code does

Jonas Riedmann and others added 6 commits April 8, 2026 20:26
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 <noreply@anthropic.com>
- 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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
@pycord-app
Copy link
Copy Markdown

pycord-app Bot commented Apr 12, 2026

Thanks for opening this pull request!
Please make sure you have read the Contributing Guidelines and Code of Conduct.

This pull request can be checked-out with:

git fetch origin pull/3202/head:pr-3202
git checkout pr-3202

This pull request can be installed with:

pip install git+https://github.com/Pycord-Development/pycord@refs/pull/3202/head

@Paillat-dev
Copy link
Copy Markdown
Member

You don't understand what you are working with, this is clearly 100% slop. Furthermore this has already been worked on in #3159. If you have issues with id comment there directly.

@github-project-automation github-project-automation Bot moved this from Todo to Done in Pycord Apr 13, 2026
@Pycord-Development Pycord-Development locked as spam and limited conversation to collaborators Apr 13, 2026
@Paillat-dev Paillat-dev added the invalid This doesn't seem right label Apr 13, 2026
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

invalid This doesn't seem right

Projects

Status: Done

Development

Successfully merging this pull request may close these issues.

2 participants