mirror of
https://github.com/J3vb/OwnCord.git
synced 2026-09-03 03:50:00 +03:00
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
114 lines
4.1 KiB
Go
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
|
|
}
|