Files
OwnCord/Server/ws/voice_e2ee.go
T
J3vbandClaude Opus 4.8 4fc21cb372 feat(server): logging & error-visibility hardening
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>
2026-07-24 11:07:12 +02:00

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
}