// Package auth: argon2id password hashing, cookie sessions, login rate limiting. package auth import ( "crypto/rand" "crypto/sha256" "crypto/subtle" "encoding/base64" "encoding/hex" "errors" "fmt" "strings" "sync" "time" "golang.org/x/crypto/argon2" "hamlab/internal/store" ) const ( argonTime = 3 argonMemory = 64 * 1024 argonThreads = 2 argonKeyLen = 32 SessionTTL = 30 * 24 * time.Hour ) func HashPassword(pw string) (string, error) { salt := make([]byte, 16) if _, err := rand.Read(salt); err != nil { return "", err } key := argon2.IDKey([]byte(pw), salt, argonTime, argonMemory, argonThreads, argonKeyLen) enc := base64.RawStdEncoding.EncodeToString return fmt.Sprintf("$argon2id$v=19$m=%d,t=%d,p=%d$%s$%s", argonMemory, argonTime, argonThreads, enc(salt), enc(key)), nil } func VerifyPassword(hash, pw string) bool { parts := strings.Split(hash, "$") if len(parts) != 6 || parts[1] != "argon2id" { return false } var m, t uint32 var p uint8 if _, err := fmt.Sscanf(parts[3], "m=%d,t=%d,p=%d", &m, &t, &p); err != nil { return false } salt, err := base64.RawStdEncoding.DecodeString(parts[4]) if err != nil { return false } want, err := base64.RawStdEncoding.DecodeString(parts[5]) if err != nil { return false } got := argon2.IDKey([]byte(pw), salt, t, m, p, uint32(len(want))) return subtle.ConstantTimeCompare(got, want) == 1 } // ---- API tokens (hub → node) ---- // NewToken returns a random bearer token and its SHA-256 hex, which is what gets stored. func NewToken() (token, hash string, err error) { b := make([]byte, 32) if _, err := rand.Read(b); err != nil { return "", "", err } token = base64.RawURLEncoding.EncodeToString(b) return token, HashToken(token), nil } func HashToken(token string) string { sum := sha256.Sum256([]byte(token)) return hex.EncodeToString(sum[:]) } // TokenMatches compares a presented token against stored hashes in constant time. func TokenMatches(token string, hashes []string) bool { h := []byte(HashToken(token)) ok := false for _, want := range hashes { if subtle.ConstantTimeCompare(h, []byte(want)) == 1 { ok = true } } return ok } // Sessions is a token store backed by the state file. Expiry is sliding. type Sessions struct { st *store.Store } func NewSessions(st *store.Store) *Sessions { s := &Sessions{st: st} _ = st.Update(func(state *store.State) { pruneExpired(state) }) return s } func pruneExpired(state *store.State) { now := time.Now() for tok, exp := range state.Sessions { if exp.Before(now) { delete(state.Sessions, tok) } } } func (s *Sessions) Create() (string, error) { b := make([]byte, 32) if _, err := rand.Read(b); err != nil { return "", err } tok := hex.EncodeToString(b) err := s.st.Update(func(state *store.State) { pruneExpired(state) state.Sessions[tok] = time.Now().Add(SessionTTL) }) return tok, err } // Valid reports whether tok is live, sliding its expiry (persisted at most hourly). func (s *Sessions) Valid(tok string) bool { if tok == "" { return false } ok := false extend := false s.st.View(func(state *store.State) { exp, found := state.Sessions[tok] if found && exp.After(time.Now()) { ok = true extend = time.Until(exp) < SessionTTL-time.Hour } }) if ok && extend { _ = s.st.Update(func(state *store.State) { if _, found := state.Sessions[tok]; found { state.Sessions[tok] = time.Now().Add(SessionTTL) } }) } return ok } func (s *Sessions) Delete(tok string) { _ = s.st.Update(func(state *store.State) { delete(state.Sessions, tok) }) } // Limiter is a single global backoff: after 5 failures, each further failure // doubles a lockout starting at 30s, capped at 10 minutes. Single-user box. // Limiter locks out a client after repeated failed logins, per client address, with a // global backstop so a botnet can't burn CPU on argon2 indefinitely. A lockout for one // address never blocks another. type Limiter struct { mu sync.Mutex clients map[string]*bucket global bucket } type bucket struct { failures int lockUntil time.Time last time.Time } var ErrLocked = errors.New("too many failed logins") const ( perClientLock = 5 // failures before a client is locked globalLock = 100 // failures (all clients) in the window before everyone waits a minute globalWindow = 5 * time.Minute ) func (l *Limiter) get(key string) *bucket { if l.clients == nil { l.clients = map[string]*bucket{} } b, ok := l.clients[key] if !ok { b = &bucket{} l.clients[key] = b } // forget clients that have been quiet for an hour if len(l.clients) > 256 { for k, x := range l.clients { if time.Since(x.last) > time.Hour && time.Now().After(x.lockUntil) { delete(l.clients, k) } } } return b } func (l *Limiter) Allow(key string) error { l.mu.Lock() defer l.mu.Unlock() now := time.Now() if now.Before(l.global.lockUntil) { return ErrLocked } if now.Sub(l.global.last) > globalWindow { l.global.failures = 0 } if b := l.get(key); now.Before(b.lockUntil) { return ErrLocked } return nil } func (l *Limiter) Fail(key string) { l.mu.Lock() defer l.mu.Unlock() now := time.Now() b := l.get(key) b.failures++ b.last = now if b.failures >= perClientLock { d := 30 * time.Second << uint(b.failures-perClientLock) if d > 10*time.Minute { d = 10 * time.Minute } b.lockUntil = now.Add(d) } if now.Sub(l.global.last) > globalWindow { l.global.failures = 0 } l.global.failures++ l.global.last = now if l.global.failures >= globalLock { l.global.lockUntil = now.Add(time.Minute) l.global.failures = 0 } } func (l *Limiter) Reset(key string) { l.mu.Lock() defer l.mu.Unlock() delete(l.clients, key) }