- Use subtle.ConstantTimeCompare for static credential checks to prevent timing side-channel attacks - Cap failCounts (100k) and rememberedCreds (10k) maps with eviction to prevent memory exhaustion from botnet-scale scanning - Sweep expired credentials on each auth attempt - Add configurable max_connections (default 500) with semaphore to limit concurrent connections and prevent goroutine/fd exhaustion Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
107 lines
2.7 KiB
Go
107 lines
2.7 KiB
Go
package auth
|
|
|
|
import (
|
|
"crypto/subtle"
|
|
"sync"
|
|
"time"
|
|
|
|
"git.t-juice.club/torjus/oubliette/internal/config"
|
|
)
|
|
|
|
const (
|
|
maxFailCountEntries = 100000
|
|
maxRememberedCredentials = 10000
|
|
)
|
|
|
|
type credKey struct {
|
|
Username string
|
|
Password string
|
|
}
|
|
|
|
type Decision struct {
|
|
Accepted bool
|
|
Reason string // "static_credential", "threshold_reached", "remembered_credential", "rejected"
|
|
}
|
|
|
|
type Authenticator struct {
|
|
mu sync.Mutex
|
|
cfg config.AuthConfig
|
|
failCounts map[string]int // IP -> consecutive failures
|
|
rememberedCreds map[credKey]time.Time // (user,pass) -> expiry
|
|
now func() time.Time // for testing
|
|
}
|
|
|
|
func NewAuthenticator(cfg config.AuthConfig) *Authenticator {
|
|
return &Authenticator{
|
|
cfg: cfg,
|
|
failCounts: make(map[string]int),
|
|
rememberedCreds: make(map[credKey]time.Time),
|
|
now: time.Now,
|
|
}
|
|
}
|
|
|
|
func (a *Authenticator) Authenticate(ip, username, password string) Decision {
|
|
a.mu.Lock()
|
|
defer a.mu.Unlock()
|
|
|
|
// 1. Check static credentials (constant-time comparison).
|
|
for _, cred := range a.cfg.StaticCredentials {
|
|
uMatch := subtle.ConstantTimeCompare([]byte(cred.Username), []byte(username))
|
|
pMatch := subtle.ConstantTimeCompare([]byte(cred.Password), []byte(password))
|
|
if uMatch == 1 && pMatch == 1 {
|
|
a.failCounts[ip] = 0
|
|
return Decision{Accepted: true, Reason: "static_credential"}
|
|
}
|
|
}
|
|
|
|
// 2. Check remembered credentials.
|
|
key := credKey{Username: username, Password: password}
|
|
if expiry, ok := a.rememberedCreds[key]; ok {
|
|
if a.now().Before(expiry) {
|
|
a.failCounts[ip] = 0
|
|
return Decision{Accepted: true, Reason: "remembered_credential"}
|
|
}
|
|
delete(a.rememberedCreds, key)
|
|
}
|
|
|
|
// 3. Increment fail count, check threshold.
|
|
a.evictIfNeeded()
|
|
a.failCounts[ip]++
|
|
if a.failCounts[ip] >= a.cfg.AcceptAfter {
|
|
a.failCounts[ip] = 0
|
|
a.rememberedCreds[key] = a.now().Add(a.cfg.CredentialTTLDuration)
|
|
return Decision{Accepted: true, Reason: "threshold_reached"}
|
|
}
|
|
|
|
return Decision{Accepted: false, Reason: "rejected"}
|
|
}
|
|
|
|
// evictIfNeeded removes stale entries when maps exceed their size limits.
|
|
// Must be called with a.mu held.
|
|
func (a *Authenticator) evictIfNeeded() {
|
|
now := a.now()
|
|
|
|
// Sweep expired remembered credentials.
|
|
for k, expiry := range a.rememberedCreds {
|
|
if now.After(expiry) {
|
|
delete(a.rememberedCreds, k)
|
|
}
|
|
}
|
|
|
|
// If remembered creds still over limit, drop oldest entries.
|
|
for len(a.rememberedCreds) > maxRememberedCredentials {
|
|
for k := range a.rememberedCreds {
|
|
delete(a.rememberedCreds, k)
|
|
break
|
|
}
|
|
}
|
|
|
|
// If fail counts over limit, drop arbitrary entries.
|
|
for len(a.failCounts) > maxFailCountEntries {
|
|
for k := range a.failCounts {
|
|
delete(a.failCounts, k)
|
|
break
|
|
}
|
|
}
|
|
}
|