// Package auth: argon2id password hashing, cookie sessions, login rate limiting. package auth import ( "crypto/rand" "crypto/subtle" "encoding/base64" "encoding/hex" "errors" "fmt" "strings" "sync" "time" "golang.org/x/crypto/argon2" "hamlet/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 } // 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. type Limiter struct { mu sync.Mutex failures int lockUntil time.Time } var ErrLocked = errors.New("too many failed logins") func (l *Limiter) Allow() error { l.mu.Lock() defer l.mu.Unlock() if time.Now().Before(l.lockUntil) { return ErrLocked } return nil } func (l *Limiter) Fail() { l.mu.Lock() defer l.mu.Unlock() l.failures++ if l.failures >= 5 { d := 30 * time.Second << uint(l.failures-5) if d > 10*time.Minute { d = 10 * time.Minute } l.lockUntil = time.Now().Add(d) } } func (l *Limiter) Reset() { l.mu.Lock() defer l.mu.Unlock() l.failures = 0 l.lockUntil = time.Time{} }