VoiceReceiver.start() captured conn.secret_key (by value) and
conn.dave_session (by reference) exactly once. Both go stale in normal
operation:
- op 4 SESSION_DESCRIPTION rotates the transport secret_key on every
voice (re)connect — the snapshot then fails NaCl decrypt forever.
- voice_state.reinit_dave_session() REPLACES conn.dave_session with a
NEW DaveSession when none existed yet, so a receiver that starts
before DAVE finishes negotiating keeps None forever and treats E2EE
as off — every frame reaches opus as ciphertext for the whole
session.
The failure is silent by construction: decrypt failures warn for the
first 10 packets then go dark, and shredded opus output raises nothing.
The operator-visible symptom is "the bot ignores me."
Fix — resolve, don't copy:
- Credentials live in ONE tuple (secret_key, dave_session,
dave_protocol_version, dave_downgraded), re-resolved from the live
connection and swapped in a single reference store; the receive
thread reads one consistent generation per packet, so a refresh can
never pair a new key with an old session.
- Refresh triggers: the voice-ws hook on op 4 and op 24 (the hook runs
AFTER received_message dispatches the op, so it reads the new
values); membership changes in the bot's channel (DAVE epoch bump)
via the existing on_voice_state_update handler; and a
25-consecutive-NaCl-failure streak as the catch-all, retried every
streak window while the failure persists.
- Unmapped-SSRC hole closed without recreating it elsewhere: with E2EE
actively on (protocol_version > 0 and outside a passthrough window),
frames for an SSRC with no user mapping are dropped (after the
existing sole-member inference) instead of being fed to opus as
ciphertext. A non-null session alone is NOT the predicate — the
session object survives protocol downgrades to 0 and passthrough
transitions, where plaintext is legitimate. Passthrough windows
mirror discord.py's own set_passthrough_mode calls: op 21
pending-downgrade opens 120s; op 22 opens a 10s grace ONLY on the
dave_downgraded True -> False edge (upgrade-after-downgrade), with
replace-semantics so the short grace supersedes a residual long
window; a receiver starting mid-pending-downgrade seeds the window
from conn.dave_pending_transitions.
- Silence made visible: decrypt failures keep emitting one warning per
250 after the first 10, and stop() logs decode-health counters
(ok / decrypt_failed / dave_unmapped_dropped).
Tests: tests/gateway/test_discord_voice_rekey.py (24) — credential
resolution/rotation, the late-DAVE-session upgrade, failure-streak
refresh + reset, op 4/24/5 hook behavior, original-hook passthrough,
the DAVE-state drop predicate (downgraded-plaintext decodes, op-21
window decodes, expired window drops, upgrade edge replaces the long
window, same-version and duplicate op 22 open nothing,
mid-pending-downgrade startup seeds), unmapped-drop vs mapped flow,
sole-member rescue, tuple-generation consistency, teardown health
line. Adjacent voice_command / voice_mixer / send / opus suites green
under the diff (111 passed).
Fixes#77968