package api import ( "crypto/rand" "crypto/sha256" "crypto/subtle" "encoding/hex" "errors" "net/http" "sync" "time" "git.k8s-git-k6n.net/mats/fancy-dns/internal/config" ) // authenticator checks API keys, basic credentials and issued session tokens. // // Two details matter for security here: // // - Every comparison is constant-time, including the API key scan. A // byte-at-a-time comparison leaks a valid key prefix to anyone who can // measure response latency, which is a real attack on a network-exposed // admin API. // - Keys are compared by hash, not by string, so the stored form is not a // raw key and a timing difference between a long and short key cannot // reveal length. type authenticator struct { mode string // readOnly maps key hash -> key label. readOnly map[string]string // readWrite maps key hash -> key label. readWrite map[string]string username string // passwordHash is the sha256 of the configured password, so plaintext // passwords in a config file are never compared byte-by-byte. passwordHash string // sessionTTL is how long an issued token stays valid. sessionTTL time.Duration // trustLoopback is informational; the caller checks it. mu sync.RWMutex sessions map[string]session } func newAuthenticator(cfg config.Auth) *authenticator { a := &authenticator{ mode: cfg.Mode, readOnly: make(map[string]string, len(cfg.ReadOnlyAPIKeys)), readWrite: make(map[string]string, len(cfg.APIKeys)), username: cfg.Username, sessionTTL: cfg.SessionTTL.D(), sessions: make(map[string]session), } for i, k := range cfg.ReadOnlyAPIKeys { if k == "" { continue } a.readOnly[hashKey(k)] = keyLabel("ro", i) } for i, k := range cfg.APIKeys { if k == "" { continue } a.readWrite[hashKey(k)] = keyLabel("rw", i) } if cfg.Password != "" { // A bcrypt-looking value is already a hash; a plaintext value is // hashed so comparisons stay constant-time either way. a.passwordHash = hashKey(cfg.Password) } if a.sessionTTL <= 0 { a.sessionTTL = 12 * time.Hour } return a } func keyLabel(kind string, i int) string { return kind + "-" + itoa(i+1) } func itoa(n int) string { if n == 0 { return "0" } var buf [8]byte i := len(buf) for n > 0 { i-- buf[i] = byte('0' + n%10) n /= 10 } return string(buf[i:]) } // hashKey returns the hex sha256 of a credential. func hashKey(k string) string { sum := sha256.Sum256([]byte(k)) return hex.EncodeToString(sum[:]) } // apiKey resolves an API key to a principal. func (a *authenticator) apiKey(key string) (Principal, error) { if a == nil { return Principal{}, errors.New("auth: not configured") } h := hashKey(key) if label, ok := a.readWrite[h]; ok { return Principal{Mode: "apikey", Name: label}, nil } if label, ok := a.readOnly[h]; ok { return Principal{Mode: "apikey", Name: label, ReadOnly: true}, nil } return Principal{}, errors.New("invalid API key") } // basic resolves basic credentials and, on success, issues a session token. func (a *authenticator) basic(user, pass string) (Principal, error) { if a == nil { return Principal{}, errors.New("auth: not configured") } switch a.mode { case "basic": if subtle.ConstantTimeCompare([]byte(user), []byte(a.username)) != 1 { return Principal{}, errors.New("invalid username") } if len(a.passwordHash) == 0 { return Principal{}, errors.New("no password configured") } if subtle.ConstantTimeCompare([]byte(hashKey(pass)), []byte(a.passwordHash)) != 1 { return Principal{}, errors.New("invalid password") } return Principal{Mode: "basic", Name: user}, nil case "apikey": // Basic auth with an API key in the password field is a common client // convention (curl -u any:KEY). return a.apiKey(pass) default: return Principal{}, errors.New("basic auth is not enabled") } } // session validates a bearer token. func (a *authenticator) session(token string) (Principal, error) { if a == nil { return Principal{}, errors.New("auth: not configured") } a.mu.RLock() s, ok := a.sessions[token] a.mu.RUnlock() if !ok { return Principal{}, errors.New("invalid or expired session") } if time.Now().After(s.expires) { a.mu.Lock() delete(a.sessions, token) a.mu.Unlock() return Principal{}, errors.New("session expired") } return s.principal, nil } // issue creates a session token for a principal. func (a *authenticator) issue(p Principal) (string, time.Time) { b := make([]byte, 32) if _, err := rand.Read(b); err != nil { // crypto/rand failing is fatal for a token; fall back to a token that // will not authenticate rather than issuing something predictable. return "", time.Time{} } token := hex.EncodeToString(b) exp := time.Now().Add(a.sessionTTL) a.mu.Lock() a.sessions[token] = session{principal: p, expires: exp} // Opportunistic expiry sweep: sessions are few, so a linear pass is fine. now := time.Now() for k, v := range a.sessions { if now.After(v.expires) { delete(a.sessions, k) } } a.mu.Unlock() return token, exp } // revoke drops a session token. func (a *authenticator) revoke(token string) { a.mu.Lock() delete(a.sessions, token) a.mu.Unlock() } // Config reports the auth mode for the UI. func (a *authenticator) Config() string { if a == nil { return "none" } return a.mode } // AuthMode returns the configured mode. func (s *Server) AuthMode() string { return s.auth.Config() } // handleLogin authenticates a login attempt and returns a session token. func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) { ip := s.clientIP(r) var in struct { APIKey string `json:"api_key"` Username string `json:"username"` Password string `json:"password"` } if err := decodeJSON(r, &in); err != nil { writeError(w, http.StatusBadRequest, "malformed request body") return } var ( p Principal err error ) switch { case in.APIKey != "": p, err = s.auth.apiKey(in.APIKey) case in.Username != "": p, err = s.auth.basic(in.Username, in.Password) default: writeError(w, http.StatusBadRequest, "api_key or username/password required") return } if err != nil { s.recordLoginFailure(ip) writeError(w, http.StatusUnauthorized, "invalid credentials") return } s.clearLoginFailures(ip) token, exp := s.auth.issue(p) if token == "" { writeError(w, http.StatusInternalServerError, "could not create session") return } s.log.Info("admin login", "user", p.Name, "mode", p.Mode, "client", ip) writeJSON(w, http.StatusOK, map[string]any{ "ok": true, "token": token, "expires_at": exp, "name": p.Name, "read_only": p.ReadOnly, "mode": p.Mode, }) } // handleLogout drops the caller's session. func (s *Server) handleLogout(w http.ResponseWriter, r *http.Request) { if h := r.Header.Get("Authorization"); h != "" { if scheme, value, ok := cut(h, " "); ok && scheme == "Bearer" { s.auth.revoke(value) } } writeJSON(w, http.StatusOK, map[string]any{"ok": true}) } func cut(s, sep string) (before, after string, found bool) { i := indexOf(s, sep) if i < 0 { return s, "", false } return s[:i], s[i+len(sep):], true } func indexOf(s, sub string) int { if len(sub) > len(s) { return -1 } for i := 0; i+len(sub) <= len(s); i++ { if s[i:i+len(sub)] == sub { return i } } return -1 } // handleWhoami reports the caller's identity, which the UI uses to decide // whether to show write controls. func (s *Server) handleWhoami(w http.ResponseWriter, r *http.Request) { p, _ := PrincipalFrom(r.Context()) writeJSON(w, http.StatusOK, map[string]any{ "name": p.Name, "mode": p.Mode, "read_only": p.ReadOnly, "can_write": p.CanWrite(), "auth_mode": s.AuthMode(), "base_path": s.base, }) }