From 745a3182da7a2e87471c83619836efb232362ea7 Mon Sep 17 00:00:00 2001 From: Mohamed Ashraf Date: Mon, 23 Feb 2026 23:33:31 +0200 Subject: [PATCH 1/2] Fix key ratchet race condition during DAVE epoch transitions When a user joins or leaves a voice channel, Discord triggers a DAVE_PROTOCOL_PREPARE_EPOCH event. The prepareEpoch() function calls Session.Init() which destroys the old MLS crypto state. Discord then sends DavePrepareTransition which calls setupKeyRatchetForUser, but at this point GetKeyRatchet() returns a Go struct wrapping a NULL C pointer because the new MLS handshake hasn't completed yet. This causes SetPassthroughMode(false) to be called on the encryptor which expects encryption keys that don't exist yet, resulting in every Encrypt() call failing with ErrMissingKeyRatchet until the new MLS Welcome message arrives (~1 second later). This fix addresses the race in two places: 1. prepareEpoch: Reset encryptor and all decryptors to passthrough mode BEFORE calling Session.Init(), so audio can continue flowing unencrypted during the brief MLS renegotiation window rather than failing entirely. 2. setupKeyRatchetForUser: Guard against nil key ratchets returned by GetKeyRatchet(). If encryption is enabled but no valid key ratchet exists yet (because the MLS handshake is still in progress), skip the transition and keep passthrough mode active. The next MLS Welcome/Commit will call prepareTransition again with valid keys. 3. newKeyRatchet: Return nil when the underlying C handle is nil, matching the existing pattern used by newWelcomeResult. This prevents wrapping NULL C pointers in Go structs that appear non-nil to callers. --- golibdave/golibdave.go | 17 +++++++++++++++-- libdave/key_ratchet.go | 4 ++++ 2 files changed, 19 insertions(+), 2 deletions(-) diff --git a/golibdave/golibdave.go b/golibdave/golibdave.go index 857625d..8f9ce3c 100644 --- a/golibdave/golibdave.go +++ b/golibdave/golibdave.go @@ -192,6 +192,11 @@ func (s *session) prepareEpoch(epoch int, protocolVersion uint16) { return } + s.encryptor.SetPassthroughMode(true) + for _, dec := range s.decryptors { + dec.TransitionToPassthroughMode(true) + } + s.session.Init(protocolVersion, uint64(s.channelID), string(s.selfUserID)) } @@ -228,17 +233,25 @@ func (s *session) setupKeyRatchetForUser(userID godave.UserID, protocolVersion u disabled := protocolVersion == disabledProtocolVersion if userID == s.selfUserID { + kr := s.session.GetKeyRatchet(string(userID)) + if !disabled && kr == nil { + return + } s.encryptor.SetPassthroughMode(disabled) if !disabled { - s.encryptor.SetKeyRatchet(s.session.GetKeyRatchet(string(userID))) + s.encryptor.SetKeyRatchet(kr) } return } decryptor := s.decryptors[userID] + kr := s.session.GetKeyRatchet(string(userID)) + if !disabled && kr == nil { + return + } decryptor.TransitionToPassthroughMode(disabled) if !disabled { - decryptor.TransitionToKeyRatchet(s.session.GetKeyRatchet(string(userID))) + decryptor.TransitionToKeyRatchet(kr) } } diff --git a/libdave/key_ratchet.go b/libdave/key_ratchet.go index c8d3b5f..2cb5e42 100644 --- a/libdave/key_ratchet.go +++ b/libdave/key_ratchet.go @@ -11,6 +11,10 @@ type KeyRatchet struct { } func newKeyRatchet(handle keyRatchetHandle) *KeyRatchet { + if handle == nil { + return nil + } + keyRatchet := &KeyRatchet{handle: handle} runtime.SetFinalizer(keyRatchet, func(k *KeyRatchet) { From 833051277e4c97abc6e41d968537b8698d7415ca Mon Sep 17 00:00:00 2001 From: Mohamed Ashraf Date: Wed, 25 Feb 2026 15:13:45 +0200 Subject: [PATCH 2/2] Remove passthrough from prepareEpoch, add diagnostic logs for nil key ratchet MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Remove SetPassthroughMode(true) in prepareEpoch — not how the reference impl handles it - Add warn logs when GetKeyRatchet returns nil after Init() resets MLS state - Keep nil guards in setupKeyRatchetForUser (the actual fix for the race) - Keep nil guard in newKeyRatchet (defensive, matches newWelcomeResult pattern) Logs will prove the prepare_epoch → prepare_transition(0) sequence triggers nil ratchets in production. --- golibdave/golibdave.go | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/golibdave/golibdave.go b/golibdave/golibdave.go index 8f9ce3c..4de12bb 100644 --- a/golibdave/golibdave.go +++ b/golibdave/golibdave.go @@ -192,11 +192,10 @@ func (s *session) prepareEpoch(epoch int, protocolVersion uint16) { return } - s.encryptor.SetPassthroughMode(true) - for _, dec := range s.decryptors { - dec.TransitionToPassthroughMode(true) - } - + s.logger.Warn("prepareEpoch: resetting MLS session via Init", + slog.Int("epoch", epoch), + slog.Int("protocol_version", int(protocolVersion)), + ) s.session.Init(protocolVersion, uint64(s.channelID), string(s.selfUserID)) } @@ -235,6 +234,10 @@ func (s *session) setupKeyRatchetForUser(userID godave.UserID, protocolVersion u if userID == s.selfUserID { kr := s.session.GetKeyRatchet(string(userID)) if !disabled && kr == nil { + s.logger.Warn("nil key ratchet for self after GetKeyRatchet", + slog.String("user_id", string(userID)), + slog.Int("protocol_version", int(protocolVersion)), + ) return } s.encryptor.SetPassthroughMode(disabled) @@ -247,6 +250,10 @@ func (s *session) setupKeyRatchetForUser(userID godave.UserID, protocolVersion u decryptor := s.decryptors[userID] kr := s.session.GetKeyRatchet(string(userID)) if !disabled && kr == nil { + s.logger.Warn("nil key ratchet for user after GetKeyRatchet", + slog.String("user_id", string(userID)), + slog.Int("protocol_version", int(protocolVersion)), + ) return } decryptor.TransitionToPassthroughMode(disabled)