Files
OwnCord/Server/ws/voice_leave.go
T
J3vb 3d18ce4f99 fix: Go E2EE security hardening — key holder tracking, base64 loose validation, rate limits, test schema
- I-1: Add key holder election in Hub (lowest userID per channel); reject
  non-key-holder voice_e2ee_offer with NOT_KEY_HOLDER error
- I-2: Accept raw (unpadded) base64 in E2EE announce/offer handlers via
  decodeBase64Loose fallback
- I-6: Copy E2EE public key value while h.mu.RLock is held in getClientE2EEPubKey
- I-7: Lower loginRateLimitPerMinute from 60 to 5
- C-1: TOCTOU fix — target channel check held under same lock as client lookup
- C-2: Include is_key_holder bool in voice_token payload so client knows
  whether to initiate key distribution
- M-5/M-6: Add ErrCodeBadPayload/ErrCodeNotKeyHolder error constants
- Fix pre-existing api build errors: block_handler.go getUserFromContext,
  router.go RequirePermission arg count
- Add user_blocks table to all test DB schemas (ws, api DM)
- Add voice_e2ee_test.go and constants_test.go covering all fixes
2026-04-04 23:16:31 +02:00

117 lines
4.2 KiB
Go

package ws
import (
"context"
"log/slog"
"time"
)
// handleVoiceLeave processes an explicit voice_leave message or a disconnect.
// 1. Gets old voiceChID from clearVoiceChID().
// 2. If was in voice: remove from DB (with retry), broadcast voice_leave.
// 3. Call livekit.RemoveParticipant (ignore errors — participant may already be gone).
func (h *Hub) handleVoiceLeave(ctx context.Context, c *Client) {
oldChID, oldJoinToken := c.clearVoiceState()
if oldChID == 0 {
slog.Debug("handleVoiceLeave no-op (already cleared)", "user_id", c.userID)
return
}
username := ""
if c.user != nil {
username = c.user.Username
}
slog.Info("voice leave",
"user_id", c.userID,
"username", username,
"channel_id", oldChID,
"remote", c.remoteAddr,
)
if err := leaveVoiceChannelWithRetry(ctx, h, c.userID, oldChID, oldJoinToken); err != nil {
c.sendMsg(buildErrorMsg(ErrCodeInternal, "voice leave failed — please rejoin if issues persist"))
}
h.BroadcastToAll(buildVoiceLeave(oldChID, c.userID))
// Re-elect key holder now that this user has left the channel.
h.updateKeyHolder(oldChID)
// E2EE keys are now managed client-side via ECDH key exchange.
// When a participant leaves, remaining clients rotate the room key
// automatically — the server has no key material to clear.
// Remove from LiveKit (best-effort).
if h.livekit != nil {
if err := h.livekit.RemoveParticipant(oldChID, c.userID, oldJoinToken); err != nil { //nolint:contextcheck // TODO: propagate context through this call path
slog.Warn("handleVoiceLeave RemoveParticipant failed (may already be gone)",
"err", err, "user_id", c.userID, "channel_id", oldChID)
}
}
}
// leaveVoiceChannelWithRetry attempts to remove the voice state from the DB
// using a channel-conditional delete. Only the row matching (userID, channelID)
// is removed — if the user has since moved to a different channel, the delete
// is a safe no-op. This prevents a race where a delayed retry could wipe a
// newer voice membership.
//
// The first attempt is synchronous. If it fails, subsequent retries run in a
// background goroutine with exponential backoff so the caller (readPump) is
// not blocked by time.Sleep. The goroutine respects ctx and the hub's stop
// channel to avoid leaking after shutdown (BUG-086).
// Returns nil on first-attempt success, the first error otherwise (retries
// continue in the background).
func leaveVoiceChannelWithRetry(ctx context.Context, h *Hub, userID int64, channelID int64, joinToken string) error {
if joinToken == "" {
slog.Warn("LeaveVoiceChannelIfMatch skipped due to missing join token",
"user_id", userID, "channel_id", channelID)
return nil
}
// Synchronous first attempt — channel-conditional delete.
if _, err := h.db.LeaveVoiceChannelIfMatch(userID, channelID, joinToken); err != nil {
slog.Warn("LeaveVoiceChannelIfMatch failed, retrying in background",
"err", err, "user_id", userID, "channel_id", channelID,
"attempt", 1, "max_retries", 3)
// Background retries — cancellable via ctx or hub stop.
go func() {
const maxRetries = 3
delay := 200 * time.Millisecond
for attempt := 2; attempt <= maxRetries; attempt++ {
select {
case <-ctx.Done():
slog.Info("LeaveVoiceChannelIfMatch retry cancelled (context)",
"user_id", userID, "channel_id", channelID, "attempt", attempt)
return
case <-h.stop:
slog.Info("LeaveVoiceChannelIfMatch retry cancelled (hub stop)",
"user_id", userID, "channel_id", channelID, "attempt", attempt)
return
case <-time.After(delay):
}
delay *= 2
if _, retryErr := h.db.LeaveVoiceChannelIfMatch(userID, channelID, joinToken); retryErr != nil {
slog.Warn("LeaveVoiceChannelIfMatch retry failed",
"err", retryErr, "user_id", userID, "channel_id", channelID,
"attempt", attempt, "max_retries", maxRetries)
if attempt == maxRetries {
slog.Error("LeaveVoiceChannelIfMatch exhausted retries — ghost state may persist",
"err", retryErr, "user_id", userID, "channel_id", channelID)
}
} else {
slog.Info("LeaveVoiceChannelIfMatch succeeded on retry",
"user_id", userID, "channel_id", channelID, "attempt", attempt)
return
}
}
}()
return err
}
return nil
}