From e35e1b346c033bf500b58bb108978c2ec73e8b42 Mon Sep 17 00:00:00 2001 From: J3vb Date: Thu, 2 Apr 2026 15:05:56 +0200 Subject: [PATCH] fix: encrypt TOTP secrets at rest and filter replay buffer by permissions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit M1 — TOTP secrets are now AES-256-GCM encrypted before being stored in the database. Key is auto-generated on first run (data/totp.key) or set via OWNCORD_TOTP_KEY env var. Existing plaintext secrets are detected and returned as-is for backwards compatibility. M3 — Replay buffer events are now tagged with their channel ID. On reconnect, the server computes the user's current accessible channels and only replays events from those channels. Global broadcasts (presence, voice state, member updates) are always replayed. Falls back to full ready payload if permission computation fails. --- Server/api/auth_handler.go | 13 +-- Server/api/auth_handler_test.go | 5 +- Server/api/coverage_push_test.go | 2 +- Server/api/invite_handler_test.go | 2 +- Server/api/router.go | 11 ++- Server/api/totp_handler.go | 36 ++++++-- Server/auth/totp_encrypt.go | 142 ++++++++++++++++++++++++++++++ Server/ws/hub.go | 2 +- Server/ws/ringbuffer.go | 43 ++++++++- Server/ws/ringbuffer_test.go | 48 +++++----- Server/ws/serve.go | 69 ++++++++++++++- 11 files changed, 325 insertions(+), 48 deletions(-) create mode 100644 Server/auth/totp_encrypt.go diff --git a/Server/api/auth_handler.go b/Server/api/auth_handler.go index 210613e9..6efb7bd3 100644 --- a/Server/api/auth_handler.go +++ b/Server/api/auth_handler.go @@ -61,8 +61,9 @@ type authSuccessResponse struct { // MountAuthRoutes registers all auth endpoints on the given router. // Rate limiters are applied per-endpoint as specified. trustedProxies is the // list of CIDRs whose X-Forwarded-For / X-Real-IP headers are honoured for -// rate-limiting IP resolution. -func MountAuthRoutes(r chi.Router, database *db.DB, limiter *auth.RateLimiter, trustedProxies []string) { +// rate-limiting IP resolution. totpKey is the AES-256 key used to encrypt +// TOTP secrets at rest (M1 security hardening). +func MountAuthRoutes(r chi.Router, database *db.DB, limiter *auth.RateLimiter, trustedProxies []string, totpKey []byte) { registerLimiter := limiter loginLimiter := limiter partialStore := auth.NewPartialAuthStore(partialAuthStoreTTL) @@ -74,10 +75,10 @@ func MountAuthRoutes(r chi.Router, database *db.DB, limiter *auth.RateLimiter, t Post("/register", handleRegister(database)) r.With(RateLimitMiddleware(loginLimiter, loginRateLimitPerMinute, time.Minute, trustedProxies)). - Post("/login", handleLogin(database, limiter, partialStore, trustedProxies)) + Post("/login", handleLogin(database, limiter, partialStore, trustedProxies, totpKey)) r.With(RateLimitMiddleware(limiter, verifyTOTPRateLimitPerMinute, time.Minute, trustedProxies)). - Post("/verify-totp", handleVerifyTOTP(database, partialStore, limiter, usedTOTPCodes)) + Post("/verify-totp", handleVerifyTOTP(database, partialStore, limiter, usedTOTPCodes, totpKey)) r.With(AuthMiddleware(database)). Post("/logout", handleLogout(database)) @@ -96,7 +97,7 @@ func MountAuthRoutes(r chi.Router, database *db.DB, limiter *auth.RateLimiter, t r.With(AuthMiddleware(database), RateLimitMiddleware(limiter, sensitiveEndpointRateLimitPerMinute, time.Minute, trustedProxies)). - Post("/api/v1/users/me/totp/confirm", handleConfirmTOTP(database, pendingTOTPStore, usedTOTPCodes, limiter)) + Post("/api/v1/users/me/totp/confirm", handleConfirmTOTP(database, pendingTOTPStore, usedTOTPCodes, limiter, totpKey)) r.With(AuthMiddleware(database), RateLimitMiddleware(limiter, sensitiveEndpointRateLimitPerMinute, time.Minute, trustedProxies)). @@ -250,7 +251,7 @@ func handleRegister(database *db.DB) http.HandlerFunc { } // handleLogin processes POST /api/v1/auth/login. -func handleLogin(database *db.DB, limiter *auth.RateLimiter, partialStore *auth.PartialAuthStore, trustedProxies []string) http.HandlerFunc { +func handleLogin(database *db.DB, limiter *auth.RateLimiter, partialStore *auth.PartialAuthStore, trustedProxies []string, totpKey []byte) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { var req loginRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { diff --git a/Server/api/auth_handler_test.go b/Server/api/auth_handler_test.go index a715772e..ad93453b 100644 --- a/Server/api/auth_handler_test.go +++ b/Server/api/auth_handler_test.go @@ -42,10 +42,13 @@ func buildAuthRouter(database *db.DB, limiter *auth.RateLimiter) http.Handler { func buildAuthRouterWithProxies(database *db.DB, limiter *auth.RateLimiter, trustedProxies []string) http.Handler { r := chi.NewRouter() - api.MountAuthRoutes(r, database, limiter, trustedProxies) + api.MountAuthRoutes(r, database, limiter, trustedProxies, testTOTPKey) return r } +// testTOTPKey is a fixed 32-byte AES-256 key used in tests. +var testTOTPKey = make([]byte, 32) + // postJSON is a test helper that POSTs JSON to the given router. func postJSON(t *testing.T, router http.Handler, path string, body any) *httptest.ResponseRecorder { t.Helper() diff --git a/Server/api/coverage_push_test.go b/Server/api/coverage_push_test.go index 549efa71..b2b03bb5 100644 --- a/Server/api/coverage_push_test.go +++ b/Server/api/coverage_push_test.go @@ -747,7 +747,7 @@ func buildCombinedRouter(t *testing.T) (http.Handler, *auth.RateLimiter, string) limiter := auth.NewRateLimiter() r := chi.NewRouter() - api.MountAuthRoutes(r, database, limiter, nil) + api.MountAuthRoutes(r, database, limiter, nil, testTOTPKey) api.MountProfileRoutes(r, database, limiter, nil) api.MountInviteRoutes(r, database) diff --git a/Server/api/invite_handler_test.go b/Server/api/invite_handler_test.go index 01f1f979..92d7f92e 100644 --- a/Server/api/invite_handler_test.go +++ b/Server/api/invite_handler_test.go @@ -15,7 +15,7 @@ import ( // buildInviteRouter returns a chi router with invite routes and auth middleware. func buildInviteRouter(database *db.DB, limiter *auth.RateLimiter) http.Handler { r := chi.NewRouter() - api.MountAuthRoutes(r, database, limiter, nil) + api.MountAuthRoutes(r, database, limiter, nil, testTOTPKey) api.MountInviteRoutes(r, database) return r } diff --git a/Server/api/router.go b/Server/api/router.go index 9591aace..e26e5d9e 100644 --- a/Server/api/router.go +++ b/Server/api/router.go @@ -71,8 +71,17 @@ func NewRouter(cfg *config.Config, database *db.DB, ver string, logBuf *admin.Ri r.Get("/info", handleInfo(cfg, ver)) }) + // Load (or auto-generate) the AES-256 key for TOTP secret encryption (M1). + totpKey, totpKeyErr := auth.LoadOrGenerateTOTPKey(cfg.Server.DataDir) + if totpKeyErr != nil { + slog.Error("failed to load TOTP encryption key", "error", totpKeyErr) + // Fall through — handlers will still work but cannot encrypt/decrypt. + // This should not happen in practice since LoadOrGenerateTOTPKey + // auto-generates a key when none exists. + } + // Auth routes: register, login, logout, me. - MountAuthRoutes(r, database, limiter, cfg.Server.TrustedProxies) + MountAuthRoutes(r, database, limiter, cfg.Server.TrustedProxies, totpKey) // Invite management routes (require MANAGE_INVITES permission). MountInviteRoutes(r, database) diff --git a/Server/api/totp_handler.go b/Server/api/totp_handler.go index b59927b8..08d18508 100644 --- a/Server/api/totp_handler.go +++ b/Server/api/totp_handler.go @@ -36,7 +36,7 @@ type totpEnableResponse struct { // ─── Handlers ──────────────────────────────────────────────────────────────── -func handleVerifyTOTP(database *db.DB, partialStore *auth.PartialAuthStore, limiter *auth.RateLimiter, usedTOTPCodes *auth.UsedTOTPCodeStore) http.HandlerFunc { +func handleVerifyTOTP(database *db.DB, partialStore *auth.PartialAuthStore, limiter *auth.RateLimiter, usedTOTPCodes *auth.UsedTOTPCodeStore, totpKey []byte) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { partialToken, ok := auth.ExtractBearerToken(r) if !ok { @@ -65,8 +65,8 @@ func handleVerifyTOTP(database *db.DB, partialStore *auth.PartialAuthStore, limi return } - totpKey := fmt.Sprintf("totp_fail:%d", challenge.UserID) - if !limiter.Check(totpKey, totpFailureRateLimit, totpFailureWindow) { + totpRateLimitKey := fmt.Sprintf("totp_fail:%d", challenge.UserID) + if !limiter.Check(totpRateLimitKey, totpFailureRateLimit, totpFailureWindow) { writeJSON(w, http.StatusTooManyRequests, errorResponse{ Error: "RATE_LIMITED", Message: "too many failed attempts, try again later", @@ -83,8 +83,18 @@ func handleVerifyTOTP(database *db.DB, partialStore *auth.PartialAuthStore, limi return } - if !auth.VerifyTOTPCodeOnce(*user.TOTPSecret, strings.TrimSpace(req.Code), time.Now().UTC(), user.ID, usedTOTPCodes) { - limiter.Allow(totpKey, totpFailureRateLimit, totpFailureWindow) + secret, decErr := auth.DecryptTOTPSecret(totpKey, *user.TOTPSecret) + if decErr != nil { + slog.Error("failed to decrypt TOTP secret", "user_id", user.ID, "error", decErr) + writeJSON(w, http.StatusInternalServerError, errorResponse{ + Error: "INTERNAL_ERROR", + Message: "failed to verify two-factor code", + }) + return + } + + if !auth.VerifyTOTPCodeOnce(secret, strings.TrimSpace(req.Code), time.Now().UTC(), user.ID, usedTOTPCodes) { + limiter.Allow(totpRateLimitKey, totpFailureRateLimit, totpFailureWindow) partialStore.RegisterFailure(partialToken, partialAuthMaxFailures) writeJSON(w, http.StatusUnauthorized, errorResponse{ Error: "UNAUTHORIZED", @@ -93,7 +103,7 @@ func handleVerifyTOTP(database *db.DB, partialStore *auth.PartialAuthStore, limi return } - limiter.Reset(totpKey) + limiter.Reset(totpRateLimitKey) if _, ok := partialStore.Consume(partialToken); !ok { writeJSON(w, http.StatusUnauthorized, errorResponse{ @@ -191,7 +201,7 @@ func handleEnableTOTP(pendingStore *auth.PendingTOTPStore, limiter *auth.RateLim } } -func handleConfirmTOTP(database *db.DB, pendingStore *auth.PendingTOTPStore, usedTOTPCodes *auth.UsedTOTPCodeStore, limiter *auth.RateLimiter) http.HandlerFunc { +func handleConfirmTOTP(database *db.DB, pendingStore *auth.PendingTOTPStore, usedTOTPCodes *auth.UsedTOTPCodeStore, limiter *auth.RateLimiter, totpKey []byte) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { user, ok := r.Context().Value(UserKey).(*db.User) if !ok || user == nil { @@ -250,7 +260,17 @@ func handleConfirmTOTP(database *db.DB, pendingStore *auth.PendingTOTPStore, use return } - if err := database.UpdateUserTOTPSecret(user.ID, &secret); err != nil { + encryptedSecret, encErr := auth.EncryptTOTPSecret(totpKey, secret) + if encErr != nil { + slog.Error("failed to encrypt TOTP secret", "user_id", user.ID, "error", encErr) + writeJSON(w, http.StatusInternalServerError, errorResponse{ + Error: "INTERNAL_ERROR", + Message: "failed to enable two-factor authentication", + }) + return + } + + if err := database.UpdateUserTOTPSecret(user.ID, &encryptedSecret); err != nil { writeJSON(w, http.StatusInternalServerError, errorResponse{ Error: "INTERNAL_ERROR", Message: "failed to enable two-factor authentication", diff --git a/Server/auth/totp_encrypt.go b/Server/auth/totp_encrypt.go new file mode 100644 index 00000000..75e706df --- /dev/null +++ b/Server/auth/totp_encrypt.go @@ -0,0 +1,142 @@ +package auth + +import ( + "crypto/aes" + "crypto/cipher" + "crypto/rand" + "encoding/hex" + "fmt" + "log/slog" + "os" + "path/filepath" +) + +const ( + // totpKeyBytes is the required length for the AES-256 encryption key. + totpKeyBytes = 32 + + // minEncryptedHexLen is the minimum hex-encoded length of a valid + // nonce+ciphertext (12-byte nonce + at least 16-byte GCM tag = 28 bytes + // = 56 hex chars). + minEncryptedHexLen = 56 +) + +// LoadOrGenerateTOTPKey returns a 32-byte AES-256 key for TOTP secret +// encryption. It checks (in order): +// 1. OWNCORD_TOTP_KEY environment variable (hex-encoded 32 bytes) +// 2. dataDir/totp.key file +// 3. Auto-generates a random key, writes it to dataDir/totp.key, and logs a warning +func LoadOrGenerateTOTPKey(dataDir string) ([]byte, error) { + // 1. Check environment variable. + if envKey := os.Getenv("OWNCORD_TOTP_KEY"); envKey != "" { + key, err := hex.DecodeString(envKey) + if err != nil { + return nil, fmt.Errorf("OWNCORD_TOTP_KEY is not valid hex: %w", err) + } + if len(key) != totpKeyBytes { + return nil, fmt.Errorf("OWNCORD_TOTP_KEY must be exactly %d bytes (got %d)", totpKeyBytes, len(key)) + } + slog.Info("loaded TOTP encryption key from OWNCORD_TOTP_KEY environment variable") + return key, nil + } + + // 2. Check key file on disk. + keyPath := filepath.Join(dataDir, "totp.key") + if data, err := os.ReadFile(keyPath); err == nil { + key, decErr := hex.DecodeString(string(data)) + if decErr != nil { + return nil, fmt.Errorf("totp.key contains invalid hex: %w", decErr) + } + if len(key) != totpKeyBytes { + return nil, fmt.Errorf("totp.key must contain exactly %d bytes (got %d)", totpKeyBytes, len(key)) + } + slog.Info("loaded TOTP encryption key from file", "path", keyPath) + return key, nil + } + + // 3. Auto-generate a new key. + key := make([]byte, totpKeyBytes) + if _, err := rand.Read(key); err != nil { + return nil, fmt.Errorf("generating TOTP encryption key: %w", err) + } + + // Ensure the data directory exists. + if err := os.MkdirAll(dataDir, 0o700); err != nil { + return nil, fmt.Errorf("creating data directory for totp.key: %w", err) + } + + if err := os.WriteFile(keyPath, []byte(hex.EncodeToString(key)), 0o600); err != nil { + return nil, fmt.Errorf("writing totp.key: %w", err) + } + + slog.Warn("auto-generated TOTP encryption key and saved to disk; "+ + "set OWNCORD_TOTP_KEY env var for production deployments", + "path", keyPath) + return key, nil +} + +// EncryptTOTPSecret encrypts a plaintext TOTP secret using AES-256-GCM. +// Returns a hex-encoded string of nonce+ciphertext. +func EncryptTOTPSecret(key []byte, plaintext string) (string, error) { + block, err := aes.NewCipher(key) + if err != nil { + return "", fmt.Errorf("creating AES cipher: %w", err) + } + + gcm, err := cipher.NewGCM(block) + if err != nil { + return "", fmt.Errorf("creating GCM: %w", err) + } + + nonce := make([]byte, gcm.NonceSize()) + if _, err := rand.Read(nonce); err != nil { + return "", fmt.Errorf("generating nonce: %w", err) + } + + ciphertext := gcm.Seal(nonce, nonce, []byte(plaintext), nil) + return hex.EncodeToString(ciphertext), nil +} + +// DecryptTOTPSecret decrypts a hex-encoded AES-256-GCM ciphertext back to the +// plaintext TOTP secret. For backwards compatibility, if the value does not +// look like valid encrypted data (not valid hex, or too short for +// nonce+tag), it is returned as-is so that existing unencrypted secrets +// continue to work. +func DecryptTOTPSecret(key []byte, ciphertext string) (string, error) { + // Backwards compatibility: if it doesn't look encrypted, return as-is. + if len(ciphertext) < minEncryptedHexLen { + return ciphertext, nil + } + + data, err := hex.DecodeString(ciphertext) + if err != nil { + // Not valid hex -- treat as unencrypted plaintext. + return ciphertext, nil + } + + block, err := aes.NewCipher(key) + if err != nil { + return "", fmt.Errorf("creating AES cipher: %w", err) + } + + gcm, err := cipher.NewGCM(block) + if err != nil { + return "", fmt.Errorf("creating GCM: %w", err) + } + + nonceSize := gcm.NonceSize() + if len(data) < nonceSize+gcm.Overhead() { + // Too short to be valid encrypted data -- return as plaintext. + return ciphertext, nil + } + + nonce, sealed := data[:nonceSize], data[nonceSize:] + plaintext, err := gcm.Open(nil, nonce, sealed, nil) + if err != nil { + // Decryption failed -- likely an unencrypted legacy secret. + // Return as-is for backwards compatibility. + return ciphertext, nil + } + + return string(plaintext), nil +} diff --git a/Server/ws/hub.go b/Server/ws/hub.go index b17e9277..7343b0fa 100644 --- a/Server/ws/hub.go +++ b/Server/ws/hub.go @@ -611,7 +611,7 @@ func (h *Hub) deliverBroadcast(bm broadcastMsg) { msg := wrapWithSeq(bm.msg, seq) // Store in replay buffer for reconnection recovery. - h.replayBuf.Push(seq, msg) + h.replayBuf.Push(seq, bm.channelID, msg) h.mu.RLock() defer h.mu.RUnlock() diff --git a/Server/ws/ringbuffer.go b/Server/ws/ringbuffer.go index c35589ca..f5e62a29 100644 --- a/Server/ws/ringbuffer.go +++ b/Server/ws/ringbuffer.go @@ -4,8 +4,9 @@ import "github.com/owncord/server/syncutil" // eventEntry stores a broadcast event for potential replay. type eventEntry struct { - seq uint64 - data []byte + seq uint64 + channelID int64 // 0 = global broadcast, >0 = channel-scoped + data []byte } // EventRingBuffer is a bounded, thread-safe ring buffer for recent broadcast events. @@ -26,10 +27,11 @@ func NewEventRingBuffer(size int) *EventRingBuffer { } // Push adds an event to the ring buffer. -func (rb *EventRingBuffer) Push(seq uint64, data []byte) { +// channelID identifies the channel scope (0 = global broadcast). +func (rb *EventRingBuffer) Push(seq uint64, channelID int64, data []byte) { rb.mu.Lock() defer rb.mu.Unlock() - rb.entries[rb.pos] = eventEntry{seq: seq, data: data} + rb.entries[rb.pos] = eventEntry{seq: seq, channelID: channelID, data: data} rb.pos = (rb.pos + 1) % rb.size if rb.count < rb.size { rb.count++ @@ -67,6 +69,39 @@ func (rb *EventRingBuffer) EventsSince(afterSeq uint64) [][]byte { return result } +// EventsSinceFiltered returns events with seq > afterSeq whose channelID is +// in allowedChannelIDs or whose channelID is 0 (global broadcasts). +// Returns nil if afterSeq is too old (same semantics as EventsSince). +func (rb *EventRingBuffer) EventsSinceFiltered(afterSeq uint64, allowedChannelIDs map[int64]bool) [][]byte { + rb.mu.RLock() + defer rb.mu.RUnlock() + + if rb.count == 0 { + return nil + } + + oldestIdx := (rb.pos - rb.count + rb.size) % rb.size + oldestSeq := rb.entries[oldestIdx].seq + + if afterSeq <= oldestSeq { + return nil + } + + result := make([][]byte, 0) + for i := 0; i < rb.count; i++ { + idx := (oldestIdx + i) % rb.size + e := rb.entries[idx] + if e.seq > afterSeq { + // channelID 0 = global broadcast, always include. + // channelID > 0 = channel-scoped, include only if allowed. + if e.channelID == 0 || allowedChannelIDs[e.channelID] { + result = append(result, e.data) + } + } + } + return result +} + // OldestSeq returns the oldest sequence number in the buffer, or 0 if empty. func (rb *EventRingBuffer) OldestSeq() uint64 { rb.mu.RLock() diff --git a/Server/ws/ringbuffer_test.go b/Server/ws/ringbuffer_test.go index fb703c1e..db9523db 100644 --- a/Server/ws/ringbuffer_test.go +++ b/Server/ws/ringbuffer_test.go @@ -12,7 +12,7 @@ import ( func TestPush_SingleEntry(t *testing.T) { rb := ws.NewEventRingBuffer(8) - rb.Push(1, []byte("hello")) + rb.Push(1, 0, []byte("hello")) // afterSeq=0 is before the oldest seq (1), so EventsSince returns nil // (the buffer can't confirm it covers everything the caller missed). @@ -33,7 +33,7 @@ func TestPush_SingleEntry(t *testing.T) { func TestPush_MultipleInOrder(t *testing.T) { rb := ws.NewEventRingBuffer(8) for i := uint64(1); i <= 5; i++ { - rb.Push(i, []byte(fmt.Sprintf("msg-%d", i))) + rb.Push(i, 0, []byte(fmt.Sprintf("msg-%d", i))) } // afterSeq == oldestSeq (1) → nil (BUG-085: conservative boundary). @@ -60,7 +60,7 @@ func TestPush_WrapsAround(t *testing.T) { // Push 6 events into a buffer with capacity 4 — first two are evicted. for i := uint64(1); i <= 6; i++ { - rb.Push(i, []byte(fmt.Sprintf("e%d", i))) + rb.Push(i, 0, []byte(fmt.Sprintf("e%d", i))) } got := rb.EventsSince(0) @@ -97,16 +97,16 @@ func TestPush_OverwritesOldest(t *testing.T) { const cap = 3 rb := ws.NewEventRingBuffer(cap) - rb.Push(1, []byte("a")) - rb.Push(2, []byte("b")) - rb.Push(3, []byte("c")) + rb.Push(1, 0, []byte("a")) + rb.Push(2, 0, []byte("b")) + rb.Push(3, 0, []byte("c")) if oldest := rb.OldestSeq(); oldest != 1 { t.Fatalf("expected oldest seq 1, got %d", oldest) } // Overwrite seq 1. - rb.Push(4, []byte("d")) + rb.Push(4, 0, []byte("d")) if oldest := rb.OldestSeq(); oldest != 2 { t.Fatalf("expected oldest seq 2 after overwrite, got %d", oldest) } @@ -139,7 +139,7 @@ func TestEventsSince_EmptyBuffer(t *testing.T) { func TestEventsSince_AfterSpecificSeq(t *testing.T) { rb := ws.NewEventRingBuffer(8) for i := uint64(1); i <= 5; i++ { - rb.Push(i, []byte(fmt.Sprintf("m%d", i))) + rb.Push(i, 0, []byte(fmt.Sprintf("m%d", i))) } got := rb.EventsSince(3) @@ -156,7 +156,7 @@ func TestEventsSince_TooOld(t *testing.T) { rb := ws.NewEventRingBuffer(cap) for i := uint64(1); i <= 6; i++ { - rb.Push(i, []byte("x")) + rb.Push(i, 0, []byte("x")) } // Oldest is seq 3. Requesting seq 1 should return nil. @@ -169,7 +169,7 @@ func TestEventsSince_TooOld(t *testing.T) { func TestEventsSince_AtLatestSeq(t *testing.T) { rb := ws.NewEventRingBuffer(8) for i := uint64(1); i <= 5; i++ { - rb.Push(i, []byte("x")) + rb.Push(i, 0, []byte("x")) } got := rb.EventsSince(5) @@ -185,7 +185,7 @@ func TestEventsSince_WraparoundOrder(t *testing.T) { // Fill past capacity to force wrap. for i := uint64(1); i <= 7; i++ { - rb.Push(i, []byte(fmt.Sprintf("v%d", i))) + rb.Push(i, 0, []byte(fmt.Sprintf("v%d", i))) } // afterSeq == oldestSeq (4) → nil (BUG-085). @@ -211,7 +211,7 @@ func TestEventsSince_AfterSeqZero_ReturnsBehavior(t *testing.T) { // the server can't confirm the buffer covers everything the client missed. rb := ws.NewEventRingBuffer(8) for i := uint64(1); i <= 3; i++ { - rb.Push(i, []byte(fmt.Sprintf("a%d", i))) + rb.Push(i, 0, []byte(fmt.Sprintf("a%d", i))) } got := rb.EventsSince(0) @@ -221,9 +221,9 @@ func TestEventsSince_AfterSeqZero_ReturnsBehavior(t *testing.T) { // If we start seqs from 0, afterSeq=0 equals oldest → nil (BUG-085). rb2 := ws.NewEventRingBuffer(8) - rb2.Push(0, []byte("z0")) - rb2.Push(1, []byte("z1")) - rb2.Push(2, []byte("z2")) + rb2.Push(0, 0, []byte("z0")) + rb2.Push(1, 0, []byte("z1")) + rb2.Push(2, 0, []byte("z2")) got = rb2.EventsSince(0) if got != nil { @@ -250,7 +250,7 @@ func TestEventsSince_AfterSeqEqualsOldest_ReturnsNil(t *testing.T) { // Push 6 events: buffer holds seq 3,4,5,6. Oldest = 3. for i := uint64(1); i <= 6; i++ { - rb.Push(i, []byte(fmt.Sprintf("e%d", i))) + rb.Push(i, 0, []byte(fmt.Sprintf("e%d", i))) } if oldest := rb.OldestSeq(); oldest != 3 { @@ -280,8 +280,8 @@ func TestOldestSeq_Empty(t *testing.T) { func TestOldestSeq_AfterInitialPushes(t *testing.T) { rb := ws.NewEventRingBuffer(8) - rb.Push(10, []byte("x")) - rb.Push(11, []byte("y")) + rb.Push(10, 0, []byte("x")) + rb.Push(11, 0, []byte("y")) if got := rb.OldestSeq(); got != 10 { t.Fatalf("expected oldest seq 10, got %d", got) @@ -292,10 +292,10 @@ func TestOldestSeq_AfterWraparound(t *testing.T) { const cap = 3 rb := ws.NewEventRingBuffer(cap) - rb.Push(10, []byte("a")) - rb.Push(20, []byte("b")) - rb.Push(30, []byte("c")) - rb.Push(40, []byte("d")) // evicts seq 10 + rb.Push(10, 0, []byte("a")) + rb.Push(20, 0, []byte("b")) + rb.Push(30, 0, []byte("c")) + rb.Push(40, 0, []byte("d")) // evicts seq 10 if got := rb.OldestSeq(); got != 20 { t.Fatalf("expected oldest seq 20 after wraparound, got %d", got) @@ -322,7 +322,7 @@ func TestConcurrent_PushAndEventsSince(t *testing.T) { go func(base uint64) { defer wg.Done() for i := uint64(0); i < pushes; i++ { - rb.Push(base+i, []byte("data")) + rb.Push(base+i, 0, []byte("data")) } }(uint64(w) * pushes) } @@ -438,7 +438,7 @@ func TestEventsSince_CapacityBoundaries(t *testing.T) { t.Run(tc.name, func(t *testing.T) { rb := ws.NewEventRingBuffer(tc.cap) for i := 1; i <= tc.pushes; i++ { - rb.Push(uint64(i), []byte(fmt.Sprintf("e%d", i))) + rb.Push(uint64(i), 0, []byte(fmt.Sprintf("e%d", i))) } got := rb.EventsSince(tc.afterSeq) diff --git a/Server/ws/serve.go b/Server/ws/serve.go index 117ba0d1..b068c8cf 100644 --- a/Server/ws/serve.go +++ b/Server/ws/serve.go @@ -106,7 +106,16 @@ func (h *Hub) upgradeAndAuth( func (h *Hub) handleReconnect( ctx context.Context, conn *websocket.Conn, c *Client, database *db.DB, lastSeq uint64, ) bool { - events := h.ReplayBuffer().EventsSince(lastSeq) + // Compute the set of channel IDs the reconnecting user can access so that + // channel-scoped replay events are filtered by current permissions (M3). + allowedChannelIDs, err := h.computeAllowedChannels(database, c.user) + if err != nil { + slog.Warn("ws handleReconnect: computeAllowedChannels failed, falling back to full ready", + "user_id", c.userID, "err", err) + return false + } + + events := h.ReplayBuffer().EventsSinceFiltered(lastSeq, allowedChannelIDs) if events == nil { return false } @@ -144,6 +153,64 @@ func (h *Hub) handleReconnect( return true } +// computeAllowedChannels returns the set of channel IDs a user may access, +// including both server channels (filtered by ReadMessages permission) and +// the user's open DM channels. This mirrors the buildReady logic so that +// replay-buffer filtering matches the ready payload's visible channels. +func (h *Hub) computeAllowedChannels(database *db.DB, user *db.User) (map[int64]bool, error) { + channels, err := database.ListChannels() + if err != nil { + return nil, fmt.Errorf("computeAllowedChannels ListChannels: %w", err) + } + + role, err := database.GetRoleByID(user.RoleID) + if err != nil { + return nil, fmt.Errorf("computeAllowedChannels GetRoleByID: %w", err) + } + + allowed := make(map[int64]bool) + + // Nil role = zero access (fail closed, same as buildReady). + if role != nil { + if permissions.HasAdmin(role.Permissions) { + // Admin bypasses all channel permission checks. + for i := range channels { + if channels[i].Type != "dm" { + allowed[channels[i].ID] = true + } + } + } else { + overrides, oErr := database.GetAllChannelPermissionsForRole(role.ID) + if oErr != nil { + return nil, fmt.Errorf("computeAllowedChannels GetAllChannelPermissionsForRole: %w", oErr) + } + for i := range channels { + if channels[i].Type == "dm" { + continue + } + o := overrides[channels[i].ID] + effective := permissions.EffectivePerms(role.Permissions, o.Allow, o.Deny) + if effective&permissions.ReadMessages == permissions.ReadMessages { + allowed[channels[i].ID] = true + } + } + } + } + + // Include the user's open DM channels. + dmChannels, dmErr := database.GetUserDMChannels(user.ID) + if dmErr != nil { + slog.Warn("computeAllowedChannels GetUserDMChannels", "err", dmErr) + // Non-fatal: DM events will simply be filtered out. + } else { + for i := range dmChannels { + allowed[dmChannels[i].ChannelID] = true + } + } + + return allowed, nil +} + func (h *Hub) handleFreshConnect( ctx context.Context, conn *websocket.Conn, c *Client, database *db.DB, ) error {