// 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" "regexp" "sort" "strings" "sync" "time" "golang.org/x/crypto/argon2" "hamlab/internal/config" "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 { return TokenIndex(token, hashes) >= 0 } // TokenIndex is which of hashes the presented token matches, -1 for none; every hash is // compared whatever the answer. func TokenIndex(token string, hashes []string) int { h := []byte(HashToken(token)) idx := -1 for i, want := range hashes { if subtle.ConstantTimeCompare(h, []byte(want)) == 1 { idx = i } } return idx } // ---- users ---- // UserNameRe is what an account may be called: like a Unix login, lower-case. var UserNameRe = regexp.MustCompile(`^[a-z0-9][a-z0-9._-]{0,31}$`) const MinPasswordLen = 12 // dummyHash is verified against when the named user does not exist, so a login attempt // takes the same time either way and the form cannot be used to list accounts. var dummyHash = func() string { h, err := HashPassword("not-a-real-password") if err != nil { return "" } return h }() // CheckUser verifies a password for one of users. A missing user — or a guest login whose // time is up — fails in the same time as a wrong password. func CheckUser(users []config.User, name, pw string) bool { for _, u := range users { if u.Name == name { if u.Expired(time.Now()) { break } return VerifyPassword(u.Hash, pw) } } VerifyPassword(dummyHash, pw) return false } // ---- guest logins ---- // guestAlphabet leaves out every letter and digit that can be misread in a screenshot or // over the phone: i, l, o, 0 and 1. A guest login is read aloud and typed by hand. const guestAlphabet = "abcdefghjkmnpqrstuvwxyz23456789" // randomFrom returns n characters of guestAlphabet, chosen without modulo bias. func randomFrom(n int) (string, error) { out := make([]byte, 0, n) buf := make([]byte, n) limit := byte(256 - 256%len(guestAlphabet)) // the top of the last whole run of the alphabet for len(out) < n { if _, err := rand.Read(buf); err != nil { return "", err } for _, b := range buf { if b >= limit { continue // would favour the first few letters; ask for another byte instead } out = append(out, guestAlphabet[int(b)%len(guestAlphabet)]) if len(out) == n { break } } } return string(out), nil } // GuestPassword is a password for a guest login: four groups of four, ~78 bits, in an // alphabet with nothing ambiguous in it. func GuestPassword() (string, error) { var groups []string for i := 0; i < 4; i++ { g, err := randomFrom(4) if err != nil { return "", err } groups = append(groups, g) } return strings.Join(groups, "-"), nil } // GuestSuffix is the tail of a generated guest name. func GuestSuffix() (string, error) { return randomFrom(4) } // ---- sessions ---- // 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, ses := range state.Sessions { if ses.Expires.Before(now) { delete(state.Sessions, tok) } } } func randomHex(n int) (string, error) { b := make([]byte, n) if _, err := rand.Read(b); err != nil { return "", err } return hex.EncodeToString(b), nil } // Create signs user in from ip with agent, returning the cookie token. func (s *Sessions) Create(user, ip, agent string) (string, error) { tok, err := randomHex(32) if err != nil { return "", err } id, err := randomHex(6) if err != nil { return "", err } if len(agent) > 200 { agent = agent[:200] } now := time.Now() err = s.st.Update(func(state *store.State) { pruneExpired(state) state.Sessions[tok] = store.Session{ID: id, User: user, Expires: now.Add(SessionTTL), Created: now, LastSeen: now, IP: ip, Agent: agent} }) return tok, err } // Lookup reports whether tok is live and whose it is, sliding its expiry and noting the // visit (persisted at most hourly, so a busy console is not writing the file all day). func (s *Sessions) Lookup(tok, ip string) (store.Session, bool) { if tok == "" { return store.Session{}, false } var ses store.Session ok, touch := false, false s.st.View(func(state *store.State) { found, have := state.Sessions[tok] if have && found.Expires.After(time.Now()) { ses, ok = found, true touch = time.Until(found.Expires) < SessionTTL-time.Hour || time.Since(found.LastSeen) > time.Hour } }) if ok && touch { _ = s.st.Update(func(state *store.State) { if x, found := state.Sessions[tok]; found { x.Expires = time.Now().Add(SessionTTL) x.LastSeen = time.Now() if ip != "" { x.IP = ip } state.Sessions[tok] = x } }) } return ses, ok } func (s *Sessions) Delete(tok string) { _ = s.st.Update(func(state *store.State) { delete(state.Sessions, tok) }) } // List is every live session of user, newest first. func (s *Sessions) List(user string) []store.Session { var out []store.Session s.st.View(func(state *store.State) { now := time.Now() for _, ses := range state.Sessions { if ses.User == user && ses.Expires.After(now) { out = append(out, ses) } } }) sort.Slice(out, func(i, j int) bool { return out[i].LastSeen.After(out[j].LastSeen) }) return out } // Count is how many live sessions each user has. func (s *Sessions) Count() map[string]int { n := map[string]int{} s.st.View(func(state *store.State) { now := time.Now() for _, ses := range state.Sessions { if ses.Expires.After(now) { n[ses.User]++ } } }) return n } // Revoke ends the session of user with this ID. Reports whether there was one. func (s *Sessions) Revoke(user, id string) bool { found := false _ = s.st.Update(func(state *store.State) { for tok, ses := range state.Sessions { if ses.User == user && ses.ID == id { delete(state.Sessions, tok) found = true } } }) return found } // RevokeOthers ends every session of user except the one holding keep. Returns how many. func (s *Sessions) RevokeOthers(user, keep string) int { n := 0 _ = s.st.Update(func(state *store.State) { for tok, ses := range state.Sessions { if ses.User == user && tok != keep { delete(state.Sessions, tok) n++ } } }) return n } // RevokeUser ends every session of user (the account is going away). func (s *Sessions) RevokeUser(user string) { s.RevokeOthers(user, "") } // 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. Past perClientLock failures, each further failure doubles a // lockout starting at 30s, capped at 10 minutes. 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 is how many failures one address gets before it is made to wait. Set high // on purpose: a guest login is a generated password read off a message and typed by hand // on a phone, and being locked out for fumbling it is worse than the brute-force risk — // which the doubling backoff past this point, and the global backstop, still cover. perClientLock = 20 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) }