package auth import ( "crypto/subtle" "sync" "time" "code.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" Shell string // optional: route to specific shell (only set for static credentials) } 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", Shell: cred.Shell} } } // 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 } } }