// SPDX-FileCopyrightText: 2026 Echolot contributors // SPDX-License-Identifier: GPL-3.0-or-later // Package adminauth handles who may administer the server. // // Two ways in, deliberately: // // - **OIDC**, the normal one. Identity lives in the operator's own IdP. // - **A local admin password**, the break-glass one. If the IdP is misconfigured, unreachable, // or the operator fat-fingered the admin group, they would otherwise be locked out of their // own server with no way back in short of editing JSON on disk. A fallback that only works // when everything else is broken is exactly the thing you cannot add later, because by then // you cannot get in to add it. // // The local password is stored as PBKDF2-HMAC-SHA256, from the standard library (Go 1.24+), with // a per-credential salt. Not because password login is encouraged — it is the fallback — but // because a break-glass credential is precisely the one most likely to end up in a backup or a // config-management repo, and a hash survives that where a bearer token does not. // // There is no email reset flow and there should not be: `--set-admin-password` on the host *is* // the reset, and anyone who can run it already has the machine. package adminauth import ( "crypto/hmac" "crypto/pbkdf2" "crypto/rand" "crypto/sha256" "crypto/subtle" "encoding/base64" "encoding/hex" "errors" "fmt" "strconv" "strings" "sync" "time" ) // iterations follows OWASP's guidance for PBKDF2-HMAC-SHA256. Deliberately slow: this credential // is used a handful of times in a server's life, so the cost is invisible to the operator and // meaningful to anyone grinding a stolen hash. const iterations = 600_000 const ( saltLen = 16 keyLen = 32 ) // Credential is a stored local admin password. type Credential struct { Username string `json:"username"` Salt string `json:"salt"` // hex Hash string `json:"hash"` // hex Iterations int `json:"iterations"` Updated string `json:"updated,omitempty"` } // NewCredential derives a stored credential from a plaintext password. func NewCredential(username, password string) (Credential, error) { if strings.TrimSpace(username) == "" { return Credential{}, errors.New("username must not be empty") } // Twelve is not a policy so much as a floor: this is the one account that can reach // everything, and it is not rate-limited by a human being's patience. if len(password) < 12 { return Credential{}, errors.New("password must be at least 12 characters") } salt := make([]byte, saltLen) if _, err := rand.Read(salt); err != nil { return Credential{}, err } key, err := pbkdf2.Key(sha256.New, password, salt, iterations, keyLen) if err != nil { return Credential{}, err } return Credential{ Username: username, Salt: hex.EncodeToString(salt), Hash: hex.EncodeToString(key), Iterations: iterations, Updated: time.Now().UTC().Format(time.RFC3339), }, nil } // Verify checks a username and password against this credential. // // Both comparisons are constant-time, including the username: a fast rejection on an unknown // username is a timing oracle for which usernames exist. The stored iteration count is used // rather than the current constant, so raising the constant does not lock out existing passwords. func (c Credential) Verify(username, password string) bool { if c.Username == "" || c.Hash == "" { return false } salt, err := hex.DecodeString(c.Salt) if err != nil { return false } want, err := hex.DecodeString(c.Hash) if err != nil { return false } iter := c.Iterations if iter <= 0 { iter = iterations } got, err := pbkdf2.Key(sha256.New, password, salt, iter, len(want)) if err != nil { return false } userOK := subtle.ConstantTimeCompare([]byte(c.Username), []byte(username)) == 1 passOK := subtle.ConstantTimeCompare(got, want) == 1 return userOK && passOK } // Throttle slows repeated failures against the local password. // // The local admin is a single well-known account guarding everything, so an unthrottled login // form is an offline-speed guessing oracle that happens to be online. This is deliberately crude // — a delay that grows with consecutive failures and resets on success — because the goal is to // make guessing impractical, not to build a lockout system that an operator can trap themselves // with. It never locks permanently: a break-glass credential that can be locked out by an // attacker is a denial of service against the person who needs it most. type Throttle struct { mu sync.Mutex failures int last time.Time now func() time.Time } func NewThrottle() *Throttle { return &Throttle{now: time.Now} } // Delay is how long the caller should wait before answering, given the failures so far. func (t *Throttle) Delay() time.Duration { t.mu.Lock() defer t.mu.Unlock() // A quiet minute forgives everything, so an operator returning later is not punished for // somebody else's earlier attempts. if !t.last.IsZero() && t.now().Sub(t.last) > time.Minute { t.failures = 0 } switch { case t.failures == 0: return 0 case t.failures < 3: return 250 * time.Millisecond case t.failures < 6: return time.Second default: return 3 * time.Second } } func (t *Throttle) Failed() { t.mu.Lock() defer t.mu.Unlock() t.failures++ t.last = t.now() } func (t *Throttle) Succeeded() { t.mu.Lock() defer t.mu.Unlock() t.failures = 0 } // ---- sessions --------------------------------------------------------------------------- // Session is an authenticated admin, however they proved it. type Session struct { // Subject is the account id: "local:" or "#" from OIDC. Subject string // Display is what the UI shows. Display string Expires time.Time } // Sessions mints and checks signed session cookies. // // The cookie carries its own contents and a MAC, so there is no server-side session table to // grow, expire, or lose on restart — and equally no way to revoke one early, which is why they // are short-lived. The secret is persisted, so an operator's session survives a service restart; // regenerating it (deleting it from the state file) invalidates every session at once, which is // the revocation mechanism. type Sessions struct { secret []byte ttl time.Duration } func NewSessions(secret []byte, ttl time.Duration) *Sessions { if ttl <= 0 { ttl = 12 * time.Hour } return &Sessions{secret: append([]byte(nil), secret...), ttl: ttl} } // NewSecret makes a fresh signing secret for first start. func NewSecret() ([]byte, error) { b := make([]byte, 32) _, err := rand.Read(b) return b, err } var ErrSession = errors.New("session is not valid") // Issue returns the cookie value for a newly authenticated admin. func (s *Sessions) Issue(subject, display string) string { exp := time.Now().Add(s.ttl).Unix() payload := base64.RawURLEncoding.EncodeToString([]byte(subject)) + "." + base64.RawURLEncoding.EncodeToString([]byte(display)) + "." + strconv.FormatInt(exp, 10) return payload + "." + s.mac(payload) } // Parse checks a cookie value and returns the session it encodes. func (s *Sessions) Parse(value string) (*Session, error) { i := strings.LastIndex(value, ".") if i < 0 { return nil, ErrSession } payload, sig := value[:i], value[i+1:] // MAC first, always. Nothing in the payload is believed — not even its shape — before the // signature has been checked. if !hmac.Equal([]byte(sig), []byte(s.mac(payload))) { return nil, ErrSession } parts := strings.Split(payload, ".") if len(parts) != 3 { return nil, ErrSession } subject, err := base64.RawURLEncoding.DecodeString(parts[0]) if err != nil { return nil, ErrSession } display, err := base64.RawURLEncoding.DecodeString(parts[1]) if err != nil { return nil, ErrSession } exp, err := strconv.ParseInt(parts[2], 10, 64) if err != nil { return nil, ErrSession } if time.Now().After(time.Unix(exp, 0)) { return nil, fmt.Errorf("%w: expired", ErrSession) } return &Session{Subject: string(subject), Display: string(display), Expires: time.Unix(exp, 0)}, nil } func (s *Sessions) mac(payload string) string { m := hmac.New(sha256.New, s.secret) m.Write([]byte(payload)) return base64.RawURLEncoding.EncodeToString(m.Sum(nil)) }