package server import ( "errors" "fmt" "net" "net/http" "strconv" "strings" "sync" "time" "cc.tiredsysadmin.keeporg/internal/secret" "cc.tiredsysadmin.keeporg/internal/store" "cc.tiredsysadmin.keeporg/internal/totp" ) // Proving who someone is: every password and one-time code the server // checks goes through verify, wherever it was typed — the API, the sign-in // form, the device page, a password change, a second-factor change or a // vault deletion. Two things follow from that being one function. // // The password hash is checked outside the store's lock. It is deliberately // slow, and under the write lock it stalled every other request, so a few // guesses a second were a denial of service. verify reads what it needs, // checks it, and commit re-checks inside the write that nothing changed in // between. // // Failures are counted per client address and per account, and a client or // account over its budget is refused before any hash is computed. The // counts live in memory: a restart forgets them, which costs an attacker a // restart they cannot cause. // errThrottled means too many failures; RetryAfter says how long to wait. type errThrottled struct{ RetryAfter time.Duration } func (e *errThrottled) Error() string { return fmt.Sprintf("too many failed attempts; try again in %s", e.RetryAfter.Round(time.Minute)) } // credentials is a password, and code, that checked out against an account // as it stood when verify read it. type credentials struct { userID string hash string step int64 // the one-time code's time step, when one was needed totp bool } // dummyHash is verified against when there is no real one, so that an // unknown, disabled or password-less account takes as long to refuse as a // wrong password: the timing would otherwise list the usernames. var dummyHash = func() string { h, err := secret.Hash("keeporg timing equaliser, never a real password") if err != nil { panic(err) } return h }() // verify checks a username, password and one-time code. func (s *Server) verify(r *http.Request, username, password, code string) (*credentials, error) { ip := "ip:" + s.clientIP(r) acct := "user:" + strings.ToLower(strings.TrimSpace(username)) if wait := s.limits.blocked(ip, acct); wait > 0 { return nil, &errThrottled{RetryAfter: wait} } var ( u store.User found bool ) s.st.Read(func(st *store.State) { if p := st.UserByName(username); p != nil { u, found = *p, true } }) if !found || u.Disabled || u.PassHash == "" { secret.Verify(dummyHash, password) s.limits.fail(ip, acct) return nil, errBadCredentials } if !secret.Verify(u.PassHash, password) { s.limits.fail(ip, acct) return nil, errBadCredentials } c := &credentials{userID: u.ID, hash: u.PassHash} if u.TOTPConfirmed { if strings.TrimSpace(code) == "" { return nil, errTOTPRequired } step, ok := totp.Match(u.TOTPSecret, code, time.Now()) if !ok || step <= u.TOTPLastStep { s.limits.fail(ip, acct) return nil, errTOTPInvalid } c.step, c.totp = step, true } s.limits.reset(acct) return c, nil } // commit re-checks verified credentials inside a write: the account must // still exist, be enabled and have the same password, and the one-time code // is spent. It returns the account to act on. func (c *credentials) commit(st *store.State) (*store.User, error) { u := st.Users[c.userID] if u == nil || u.Disabled || u.PassHash != c.hash { return nil, errBadCredentials } if c.totp && u.TOTPConfirmed { if c.step <= u.TOTPLastStep { return nil, errTOTPInvalid } u.TOTPLastStep = c.step } return u, nil } // signIn verifies credentials and, inside one write, re-checks them and runs // then on the account. It is the shape of every sign-in. func (s *Server) signIn(r *http.Request, username, password, code string, then func(st *store.State, u *store.User) error) error { c, err := s.verify(r, username, password, code) if err != nil { return err } return s.st.Update(func(st *store.State) error { u, err := c.commit(st) if err != nil { return err } return then(st, u) }) } // reconfirm proves, for a change that weakens or replaces how an account // signs in, that the person holding the session also holds the password and // the current code. Unlike a sign-in it does not say which of the two was // wrong: a stolen session must not become a password oracle. func (s *Server) reconfirm(r *http.Request, u *store.User, password, code string, then func(st *store.State, u *store.User) error) error { // The account as stored now, not as the session saw it at the start of // the request. s.st.Read(func(st *store.State) { if cur := st.Users[u.ID]; cur != nil { cp := *cur u = &cp } }) if u.PassHash == "" { return errNoPassword } if password == "" { return errReauth } if u.TOTPConfirmed && strings.TrimSpace(code) == "" { // Asked before the password is checked, so it says nothing about // whether the password was right. return errTOTPRequired } err := s.signIn(r, u.Username, password, code, then) if errors.Is(err, errTOTPInvalid) || errors.Is(err, errTOTPRequired) { return errBadCredentials } return err } // writeThrottled answers a refused attempt with 429 and Retry-After. func writeThrottled(w http.ResponseWriter, err error) bool { var t *errThrottled if !errors.As(err, &t) { return false } w.Header().Set("Retry-After", strconv.Itoa(int(t.RetryAfter.Seconds())+1)) fail(w, http.StatusTooManyRequests, "throttled", t.Error()) return true } // ---- limits ---- // limitWindow is how long failures are remembered. const limitWindow = 15 * time.Minute // Budgets per window. An address may fail more often than an account, since // several people may share one address; an account fails rarely when its // owner is the one typing. var limitBudget = map[string]int{ "ip": 30, "user": 10, "start": 30, // unauthenticated device and single sign-on starts, per address } type attempts struct { n int since time.Time } // limiter counts failures per key ("ip:…", "user:…", "start:…"). type limiter struct { mu sync.Mutex m map[string]*attempts now func() time.Time } func newLimiter() *limiter { return &limiter{m: map[string]*attempts{}, now: time.Now} } func budget(key string) int { kind, _, _ := strings.Cut(key, ":") return limitBudget[kind] } // blocked says how long until every key is under budget again, or 0. func (l *limiter) blocked(keys ...string) time.Duration { l.mu.Lock() defer l.mu.Unlock() now := l.now() var wait time.Duration for _, k := range keys { a := l.m[k] if a == nil || now.Sub(a.since) >= limitWindow || a.n < budget(k) { continue } if left := limitWindow - now.Sub(a.since); left > wait { wait = left } } return wait } // fail counts one failure against each key. func (l *limiter) fail(keys ...string) { l.mu.Lock() defer l.mu.Unlock() now := l.now() if len(l.m) > 50_000 { for k, a := range l.m { if now.Sub(a.since) >= limitWindow { delete(l.m, k) } } } for _, k := range keys { a := l.m[k] if a == nil || now.Sub(a.since) >= limitWindow { a = &attempts{since: now} l.m[k] = a } a.n++ } } // take counts one use of key and reports whether it was within budget, for // endpoints limited by use rather than by failure. func (l *limiter) take(key string) time.Duration { if wait := l.blocked(key); wait > 0 { return wait } l.fail(key) return 0 } func (l *limiter) reset(key string) { l.mu.Lock() delete(l.m, key) l.mu.Unlock() } // ---- client addresses ---- // clientIP is the address a request came from. Behind a reverse proxy every // request comes from the proxy, so for a peer listed in trusted_proxies the // address is taken from X-Forwarded-For, right to left, skipping trusted // hops; nobody else gets to choose their own address with a header. func (s *Server) clientIP(r *http.Request) string { host, _, err := net.SplitHostPort(r.RemoteAddr) if err != nil { host = r.RemoteAddr } if !s.trusted(host) { return host } hops := strings.Split(r.Header.Get("X-Forwarded-For"), ",") for i := len(hops) - 1; i >= 0; i-- { h := strings.TrimSpace(hops[i]) if h == "" { continue } if !s.trusted(h) { return h } host = h } if real := strings.TrimSpace(r.Header.Get("X-Real-IP")); real != "" && net.ParseIP(real) != nil { return real } return host } func (s *Server) trusted(addr string) bool { ip := net.ParseIP(addr) if ip == nil { return false } for _, n := range s.proxies { if n.Contains(ip) { return true } } return false } // parseProxies reads trusted_proxies: addresses or CIDR ranges. func parseProxies(list []string) ([]*net.IPNet, error) { var out []*net.IPNet for _, p := range list { p = strings.TrimSpace(p) if p == "" { continue } if !strings.Contains(p, "/") { if ip := net.ParseIP(p); ip != nil && ip.To4() != nil { p += "/32" } else { p += "/128" } } _, n, err := net.ParseCIDR(p) if err != nil { return nil, fmt.Errorf("trusted_proxies: %q: %w", p, err) } out = append(out, n) } return out, nil } // maxLabel bounds a session or device label a client chooses: it is shown // in lists and stored with every session, and nothing needs more. const maxLabel = 64 // clampLabel trims a client-chosen label to something displayable. func clampLabel(label string) string { label = strings.Map(func(r rune) rune { if r < 0x20 || r == 0x7f { return -1 } return r }, strings.TrimSpace(label)) if r := []rune(label); len(r) > maxLabel { label = string(r[:maxLabel]) } return label }