Files
OwnCord/Server/ws/voice_leave.go
T
Claude 0c4d9f702c feat: implement true E2EE for voice via client-side ECDH key exchange
Replace server-generated symmetric keys with client-side ECDH P-256 key
exchange. The server now only relays opaque public keys and encrypted
room key blobs — it never sees the actual room encryption key.

Protocol:
- voice_e2ee_announce: clients broadcast ECDH public keys
- voice_e2ee_offer: key holder wraps room key for each peer via ECDH+HKDF+AES-GCM
- Key rotation on participant leave (forward secrecy)

Server changes:
- Remove VoiceE2EEKeys (server-side key generation)
- Add relay handlers for announce/offer messages
- Store per-client ECDH public keys on Client struct
- Send existing public keys to new joiners during voice state sync

Client changes:
- New e2eeCrypto.ts: ECDH P-256, HKDF-SHA256, AES-256-GCM key wrapping
- LiveKitSession generates keypair on join, manages key holder election
- Key holder generates room key and wraps for each peer
- Non-holders wait for offer before connecting to LiveKit
- Room key rotated when any participant leaves

https://claude.ai/code/session_01KKo3RwjdmcNzkgXNfUkgNT
2026-04-04 19:02:21 +00:00

114 lines
4.1 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))
// 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
}