fix: encrypt TOTP secrets at rest and filter replay buffer by permissions

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.
This commit is contained in:
J3vb
2026-04-02 15:05:56 +02:00
parent 9e48e8d8e8
commit e35e1b346c
11 changed files with 325 additions and 48 deletions
+7 -6
View File
@@ -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 {
+4 -1
View File
@@ -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()
+1 -1
View File
@@ -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)
+1 -1
View File
@@ -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
}
+10 -1
View File
@@ -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)
+28 -8
View File
@@ -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",
+142
View File
@@ -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
}
+1 -1
View File
@@ -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()
+39 -4
View File
@@ -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()
+24 -24
View File
@@ -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)
+68 -1
View File
@@ -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 {