Summary
RTPReadStream.WriteAsync in Discord.Net.WebSocket strips the RTP header
but does not check the RTP P (padding) bit per RFC 3550 §5.1. When the
bit is set, trailing padding bytes are forwarded into the next stream —
which, for DAVE-encrypted voice, is the DaveDecryptStream. libdave fails
AEAD verification on the ciphertext + padding concatenation and logs
Failed to decrypt audio packet for {userId}: DecryptionFailure.
Real-world impact: observed 3–15% of incoming voice frames dropped in a
single-user private voice channel on 3.19.1, just from the bot being
connected. Audio after the padded region is silently lost. Also generates
a lot of log noise.
Reproduction
- .NET 10 bot on Windows,
DiscordSocketClient with
EnableVoiceDaveEncryption = true.
- One user + bot in a private voice channel.
- User speaks continuously; Discord's client intermittently sends padded
packets (sometimes in bursts of ~50 within 100 ms — appears correlated
with silence / DTX boundaries).
- Every padded packet is logged as
DecryptionFailure and its payload is
dropped.
Root cause
In src/Discord.Net.WebSocket/Audio/Streams/RTPReadStream.cs:
public override Task WriteAsync(byte[] buffer, int offset, int count, CancellationToken cancelToken)
{
cancelToken.ThrowIfCancellationRequested();
int headerSize = GetHeaderSize(buffer, offset);
// …seq, timestamp…
_next.WriteHeader(seq, timestamp, false);
return _next.WriteAsync(buffer, offset + headerSize, count - headerSize, cancelToken);
// ^^^^^^^^^^^^^^^^^^ includes padding
}
The count - headerSize slice always includes any trailing padding the
sender added. RFC 3550 §5.1:
If the padding bit is set, the packet contains one or more additional
padding octets at the end which are not part of the payload. The last
octet of the padding contains a count of how many padding octets should
be ignored, including itself.
Padded packets therefore arrive at the DAVE decryptor with extra bytes
appended after the DAVE trailer, and AEAD verification rightly rejects
them.
Evidence (instrumented client, same bot & speaker, three runs)
| Run |
Decrypt failures |
Attempts |
Rate |
| Stock 3.19.1 |
63 |
~1,650 |
3.8% |
| + RFC padding strip |
52 |
~2,710 |
1.9% (all empty-payload keepalives) |
| + drop empty-payload after strip |
0 |
3,320 |
0.0% |
Headers/tails of the "failures" before the fix: variable first-4 bytes,
tails consistently runs of the same byte (0E0E…0E, 0D0D…0D, FFFF…FF,
2020…20, 1010…10, etc.) — textbook RTP padding. Successful packets
end in a DAVE trailer (... 0D FA FA).
One packet burst we captured (sample):
15:13:52.022 FAIL size=201 tail=0E0E0E0E0E0E0E0E result=DecryptionFailure
15:13:52.022 FAIL size=201 tail=0D0D0D0D0D0D0D0D result=DecryptionFailure
15:13:52.022 FAIL size=201 tail=0B0B0B0B0B0B0B0B result=DecryptionFailure
15:13:52.022 FAIL size=201 tail=C9C9C9C9C9C9C9C9 result=DecryptionFailure
… 47 more all-0xFF packets …
15:13:52.121 FAIL size=255 tail=FFFFFFFFFFFFFFFF result=DecryptionFailure
Proposed fix
int paddingBytes = 0;
if ((buffer[offset] & 0b0010_0000) != 0 && count > 0)
{
paddingBytes = buffer[offset + count - 1];
if (paddingBytes > count - headerSize)
paddingBytes = 0; // malformed — don't overshoot into the header
}
int payloadLength = count - headerSize - paddingBytes;
if (payloadLength <= 0)
{
// Pure-padding keepalive / DTX marker — drop silently.
return Task.CompletedTask;
}
_next.WriteHeader(seq, timestamp, false);
return _next.WriteAsync(buffer, offset + headerSize, payloadLength, cancelToken);
Scope
Probably explains a significant fraction of reports currently tracked
as:
[Bug]: Voice receiving broken
[Bug]: Dave audio issues with multiple users in the channel
[Bug]: User speaking as they join a call makes the bot unable to hear them
Each of those could plausibly be caused by a different peer client
hitting the padding path more aggressively.
PR with the fix + spike notes coming right after this.
Summary
RTPReadStream.WriteAsyncinDiscord.Net.WebSocketstrips the RTP headerbut does not check the RTP P (padding) bit per RFC 3550 §5.1. When the
bit is set, trailing padding bytes are forwarded into the next stream —
which, for DAVE-encrypted voice, is the
DaveDecryptStream. libdave failsAEAD verification on the ciphertext + padding concatenation and logs
Failed to decrypt audio packet for {userId}: DecryptionFailure.Real-world impact: observed 3–15% of incoming voice frames dropped in a
single-user private voice channel on 3.19.1, just from the bot being
connected. Audio after the padded region is silently lost. Also generates
a lot of log noise.
Reproduction
DiscordSocketClientwithEnableVoiceDaveEncryption = true.packets (sometimes in bursts of ~50 within 100 ms — appears correlated
with silence / DTX boundaries).
DecryptionFailureand its payload isdropped.
Root cause
In
src/Discord.Net.WebSocket/Audio/Streams/RTPReadStream.cs:The
count - headerSizeslice always includes any trailing padding thesender added. RFC 3550 §5.1:
Padded packets therefore arrive at the DAVE decryptor with extra bytes
appended after the DAVE trailer, and AEAD verification rightly rejects
them.
Evidence (instrumented client, same bot & speaker, three runs)
Headers/tails of the "failures" before the fix: variable first-4 bytes,
tails consistently runs of the same byte (
0E0E…0E,0D0D…0D,FFFF…FF,2020…20,1010…10, etc.) — textbook RTP padding. Successful packetsend in a DAVE trailer (
... 0D FA FA).One packet burst we captured (sample):
Proposed fix
Scope
Probably explains a significant fraction of reports currently tracked
as:
[Bug]: Voice receiving broken[Bug]: Dave audio issues with multiple users in the channel[Bug]: User speaking as they join a call makes the bot unable to hear themEach of those could plausibly be caused by a different peer client
hitting the padding path more aggressively.
PR with the fix + spike notes coming right after this.