mirror of
https://github.com/J3vb/OwnCord.git
synced 2026-09-03 03:50:00 +03:00
Make server failures debuggable without leaking secrets: - configurable stdout log level (config.yaml logging.level + OWNCORD_LOGGING_LEVEL) - preserve the DB cause in ErrInternal wraps; log auth-DB failures distinctly from bad tokens; log the previously-silent expired-session cleanup goroutine - route HTTP handler panics through slog (was chi stderr-only, invisible to the admin log stream) - stackutil: argument-free panic stacks so key/token bytes never reach the admin ring buffer / SSE; slog.LogValuer redaction on VoiceConfig/GitHubConfig/ GIFConfig/Config and db.User/db.Session - logctx: req_id/trace_id correlation on ...Context log calls Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
283 lines
10 KiB
Go
283 lines
10 KiB
Go
package ws
|
|
|
|
import (
|
|
"context"
|
|
"encoding/base64"
|
|
"fmt"
|
|
"log/slog"
|
|
"time"
|
|
)
|
|
|
|
// Voice E2EE rate limits. Both the announce and offer relays fan out to every
|
|
// other voice participant (and an offer can force a key rotation / disconnect
|
|
// for peers), so a single user must not be able to spam them. Mirrors the
|
|
// named-constant Limiter idiom used by the voice control handlers.
|
|
const (
|
|
voiceE2EERateLimit = 5
|
|
voiceE2EEWindow = time.Second
|
|
)
|
|
|
|
// validateBase64Loose checks that s is valid padded (StdEncoding) or unpadded
|
|
// (RawStdEncoding) standard-alphabet base64. ECDH public keys exported from
|
|
// WebCrypto omit '=' padding; we accept both forms to avoid breaking existing
|
|
// clients.
|
|
// Note: URL-safe base64 (alphabet '-_') is not accepted; clients must use the
|
|
// standard alphabet ('+/').
|
|
func validateBase64Loose(s string) error {
|
|
if _, err := base64.StdEncoding.DecodeString(s); err == nil {
|
|
return nil
|
|
}
|
|
_, err := base64.RawStdEncoding.DecodeString(s)
|
|
return err
|
|
}
|
|
|
|
// updateKeyHolder scans connected clients to find the one with the lowest
|
|
// userID currently in channelID, and records them as the key holder.
|
|
// If no clients remain in the channel the entry is deleted.
|
|
// Must NOT be called while h.mu or h.keyHolderMu is held.
|
|
// Lock order: keyHolderMu (write) → h.mu (read). Holding keyHolderMu for
|
|
// the entire scan+write prevents two concurrent calls from racing and
|
|
// overwriting each other with a stale result.
|
|
func (h *Hub) updateKeyHolder(channelID int64) {
|
|
h.keyHolderMu.Lock()
|
|
defer h.keyHolderMu.Unlock()
|
|
|
|
h.mu.RLock()
|
|
var minUserID int64
|
|
found := false
|
|
for uid, c := range h.clients {
|
|
if c.getVoiceChID() == channelID {
|
|
if !found || uid < minUserID {
|
|
minUserID = uid
|
|
found = true
|
|
}
|
|
}
|
|
}
|
|
h.mu.RUnlock()
|
|
|
|
if found {
|
|
h.voiceKeyHolders[channelID] = minUserID
|
|
} else {
|
|
delete(h.voiceKeyHolders, channelID)
|
|
}
|
|
}
|
|
|
|
// IsVoiceKeyHolder reports whether userID is the current key holder for channelID.
|
|
// Satisfies the KeyHolderChecker interface.
|
|
//
|
|
// NOTE: When called from a V2 handler (via deps), there is a TOCTOU window
|
|
// between this check and the subsequent event delivery in EmitEvents. See the
|
|
// comment on handleVoiceE2EEOfferV2 for details.
|
|
func (h *Hub) IsVoiceKeyHolder(channelID, userID int64) bool {
|
|
h.keyHolderMu.RLock()
|
|
kh, ok := h.voiceKeyHolders[channelID]
|
|
h.keyHolderMu.RUnlock()
|
|
return ok && kh == userID
|
|
}
|
|
|
|
// computeIsKeyHolder determines whether userID will become the key holder when
|
|
// joining channelID. Returns true if no connected client in that channel has a
|
|
// lower userID. This is used before calling setVoiceState so the result can be
|
|
// included in the voice_token message sent to the joiner.
|
|
func (h *Hub) computeIsKeyHolder(channelID, userID int64) bool {
|
|
h.mu.RLock()
|
|
defer h.mu.RUnlock()
|
|
for uid, c := range h.clients {
|
|
if c.getVoiceChID() == channelID && uid < userID {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
// handleVoiceE2EEAnnounceV2 is the V2 (pure) handler for voice_e2ee_announce.
|
|
// It validates the public key and returns a SetE2EEPubKey mutation plus a
|
|
// VoiceE2EEAnnounceEvent for relay to other voice channel participants.
|
|
func handleVoiceE2EEAnnounceV2(_ context.Context, cmd Command, info ClientInfo, deps any) Result {
|
|
d := deps.(VoiceDeps)
|
|
announceCmd := cmd.(VoiceE2EEAnnounceCmd)
|
|
userID := info.UserID
|
|
voiceChID := info.VoiceChannelID
|
|
|
|
ratKey := fmt.Sprintf("voice_e2ee_announce:%d", userID)
|
|
if d.Limiter != nil && !d.Limiter.Allow(ratKey, voiceE2EERateLimit, voiceE2EEWindow) {
|
|
return Result{Error: ClientError{Code: ErrCodeRateLimited, Message: "too many e2ee announcements"}}
|
|
}
|
|
|
|
if voiceChID == 0 {
|
|
return Result{Error: ClientError{Code: ErrCodeVoiceError, Message: "not in a voice channel"}}
|
|
}
|
|
|
|
pubKey := announceCmd.PublicKey()
|
|
if pubKey == "" {
|
|
return Result{Error: ClientError{Code: ErrCodeBadPayload, Message: "public_key is required"}}
|
|
}
|
|
if len(pubKey) > 128 {
|
|
return Result{Error: ClientError{Code: ErrCodeBadPayload, Message: "public_key too large"}}
|
|
}
|
|
if err := validateBase64Loose(pubKey); err != nil {
|
|
return Result{Error: ClientError{Code: ErrCodeBadPayload, Message: "public_key is not valid base64"}}
|
|
}
|
|
|
|
// signature (F3 TOFU) is optional — legacy clients omit it and the
|
|
// receiving client enforces the fail-closed posture. When present it is
|
|
// validated and carried verbatim: the server relays, never verifies.
|
|
sig := announceCmd.Signature()
|
|
if sig != "" {
|
|
if len(sig) > 128 {
|
|
return Result{Error: ClientError{Code: ErrCodeBadPayload, Message: "signature too large"}}
|
|
}
|
|
if err := validateBase64Loose(sig); err != nil {
|
|
return Result{Error: ClientError{Code: ErrCodeBadPayload, Message: "signature is not valid base64"}}
|
|
}
|
|
}
|
|
|
|
msg := buildVoiceE2EEAnnounce(userID, pubKey, sig)
|
|
result := Result{
|
|
SetE2EEPubKey: &pubKey,
|
|
Events: []Event{VoiceE2EEAnnounceEvent{
|
|
voiceChannelID: voiceChID,
|
|
excludeUserID: userID,
|
|
payload: msg,
|
|
}},
|
|
}
|
|
if sig != "" {
|
|
result.SetE2EESignature = &sig
|
|
}
|
|
return result
|
|
}
|
|
|
|
// handleVoiceE2EEOfferV2 is the V2 (pure) handler for voice_e2ee_offer.
|
|
// It validates the payload and key holder status, then returns a
|
|
// VoiceE2EEOfferGuardedEvent for atomic check-and-send delivery.
|
|
//
|
|
// KNOWN RACE: There is a window between the IsVoiceKeyHolder check below
|
|
// and the sendToUserIfInVoiceChannel delivery in EmitEvents where the key
|
|
// holder map can change (e.g. the real key holder leaves voice). This is
|
|
// accepted because VoiceChannelGuardedEvent uses atomic check-and-send
|
|
// under h.mu.RLock, guaranteeing the target is still in the voice channel
|
|
// at delivery time. The worst case is a stale "not key holder" rejection
|
|
// that the client retries.
|
|
// TODO: consider re-checking key-holder status inside sendToUserIfInVoiceChannel
|
|
// under the same h.mu.RLock to close the TOCTOU window completely.
|
|
func handleVoiceE2EEOfferV2(_ context.Context, cmd Command, info ClientInfo, deps any) Result {
|
|
d := deps.(VoiceDeps)
|
|
offerCmd := cmd.(VoiceE2EEOfferCmd)
|
|
voiceChID := info.VoiceChannelID
|
|
|
|
// A legitimate rotation is a burst of one offer per peer (fired on
|
|
// join/leave and the periodic re-key), so the budget must not depend on
|
|
// channel size — keyed per sender alone, the 6th+ peer's offer was
|
|
// silently rate-limited and that peer could never decrypt audio again.
|
|
// Keying per (sender, target) admits any single rotation regardless of
|
|
// participant count while still capping repeated offers at one victim —
|
|
// the abuse this limit exists for, since an offer can force the target to
|
|
// re-key or disconnect. Cross-target spray stays bounded per victim and
|
|
// requires holding key-holder status in that channel.
|
|
ratKey := fmt.Sprintf("voice_e2ee_offer:%d:%d", info.UserID, offerCmd.TargetUserID())
|
|
if d.Limiter != nil && !d.Limiter.Allow(ratKey, voiceE2EERateLimit, voiceE2EEWindow) {
|
|
return Result{Error: ClientError{Code: ErrCodeRateLimited, Message: "too many e2ee offers"}}
|
|
}
|
|
|
|
if voiceChID == 0 {
|
|
return Result{Error: ClientError{Code: ErrCodeVoiceError, Message: "not in a voice channel"}}
|
|
}
|
|
|
|
targetUserID := offerCmd.TargetUserID()
|
|
encKey := offerCmd.EncryptedKey()
|
|
iv := offerCmd.IV()
|
|
|
|
if targetUserID <= 0 || encKey == "" || iv == "" {
|
|
return Result{Error: ClientError{Code: ErrCodeBadPayload, Message: "target_user_id, encrypted_key, and iv are required"}}
|
|
}
|
|
if len(encKey) > 1024 || len(iv) > 128 {
|
|
return Result{Error: ClientError{Code: ErrCodeBadPayload, Message: "encrypted_key or iv too large"}}
|
|
}
|
|
if err := validateBase64Loose(encKey); err != nil {
|
|
return Result{Error: ClientError{Code: ErrCodeBadPayload, Message: "encrypted_key is not valid base64"}}
|
|
}
|
|
if err := validateBase64Loose(iv); err != nil {
|
|
return Result{Error: ClientError{Code: ErrCodeBadPayload, Message: "iv is not valid base64"}}
|
|
}
|
|
|
|
if d.KeyHolder == nil {
|
|
return Result{Error: ClientError{Code: ErrCodeInternal, Message: "key holder checker not configured"}}
|
|
}
|
|
if !d.KeyHolder.IsVoiceKeyHolder(voiceChID, info.UserID) {
|
|
return Result{Error: ClientError{Code: ErrCodeNotKeyHolder, Message: "only the key holder may send key offers"}}
|
|
}
|
|
|
|
msg := buildVoiceE2EEOffer(info.UserID, encKey, iv)
|
|
return Result{
|
|
Events: []Event{VoiceE2EEOfferGuardedEvent{
|
|
voiceChannelID: voiceChID,
|
|
targetUserID: targetUserID,
|
|
payload: msg,
|
|
}},
|
|
}
|
|
}
|
|
|
|
// sendToUserIfInVoiceChannel atomically verifies that targetUserID is in the
|
|
// given voice channel and sends the message — all under a single h.mu.RLock.
|
|
// This prevents TOCTOU races where the target leaves voice between the check
|
|
// and the send. Used by VoiceChannelGuardedEvent (voice_e2ee_offer).
|
|
func (h *Hub) sendToUserIfInVoiceChannel(voiceChannelID, targetUserID int64, msg []byte) {
|
|
h.mu.RLock()
|
|
defer h.mu.RUnlock()
|
|
|
|
target, ok := h.clients[targetUserID]
|
|
if !ok {
|
|
// Undeliverable key offer can leave the peer unable to decrypt — the
|
|
// payload is dropped, but log (IDs only, never the encrypted key) so
|
|
// the failure is diagnosable rather than silent.
|
|
slog.Debug("e2ee: key offer dropped, target not connected",
|
|
"target_user_id", targetUserID, "voice_channel_id", voiceChannelID)
|
|
return
|
|
}
|
|
if target.getVoiceChID() != voiceChannelID {
|
|
slog.Debug("e2ee: key offer dropped, target not in expected voice channel",
|
|
"target_user_id", targetUserID, "voice_channel_id", voiceChannelID,
|
|
"target_voice_channel_id", target.getVoiceChID())
|
|
return
|
|
}
|
|
target.sendMsg(msg)
|
|
}
|
|
|
|
// sendToVoiceChannelExcept sends a message to all clients in the given voice
|
|
// channel except the one identified by excludeUserID.
|
|
func (h *Hub) sendToVoiceChannelExcept(channelID int64, excludeUserID int64, msg []byte) {
|
|
h.mu.RLock()
|
|
defer h.mu.RUnlock()
|
|
|
|
for uid, client := range h.clients {
|
|
if uid == excludeUserID {
|
|
continue
|
|
}
|
|
if client.getVoiceChID() == channelID {
|
|
client.sendMsg(msg)
|
|
}
|
|
}
|
|
}
|
|
|
|
// getClientE2EEPubKey returns the stored ECDH public key and its identity
|
|
// signature ("" for legacy announces) for a connected user.
|
|
// I-6 fix: Copy the values while h.mu.RLock is still held so the client
|
|
// cannot be garbage collected between the lookup and the key read.
|
|
func (h *Hub) getClientE2EEPubKey(userID int64) (string, string) {
|
|
h.mu.RLock()
|
|
c, ok := h.clients[userID]
|
|
if !ok {
|
|
h.mu.RUnlock()
|
|
return "", ""
|
|
}
|
|
key, sig := c.getE2EEPubKey()
|
|
h.mu.RUnlock()
|
|
return key, sig
|
|
}
|
|
|
|
// GetClientE2EEPubKeyForTest is an exported wrapper for tests.
|
|
func (h *Hub) GetClientE2EEPubKeyForTest(userID int64) string {
|
|
key, _ := h.getClientE2EEPubKey(userID)
|
|
return key
|
|
}
|