adminauth: a break-glass local admin alongside OIDC
server-test / test (push) Successful in 44s

If the IdP is misconfigured, unreachable, or the admin group is a typo, the
operator is locked out of their own server with no way back short of editing
JSON on disk. A fallback that only matters when everything else is broken is
exactly the thing you cannot add later - by then you cannot get in to add it.

Stored as PBKDF2-HMAC-SHA256 from the standard library (Go 1.24+ has it, so no
dependency), 600k iterations, per-credential salt. A password rather than a
bearer token on purpose: a break-glass credential is the one most likely to end
up in a backup or a config-management repo, and a hash survives that where a
token does not. There is no email reset flow and should not be -
--set-admin-password on the host is the reset, and whoever can run it already
has the machine.

The password is read from stdin, never a flag, so it stays out of shell history
and the process list; piping still works for automation.

Details the tests pin, each for a reason:
  - the username is compared in constant time too, or a fast rejection is a
    timing oracle for which usernames exist;
  - the *stored* iteration count is used, so raising the constant later does not
    lock out existing passwords;
  - the throttle grows with consecutive failures but stays bounded and forgives
    after a quiet minute - a break-glass credential an attacker can lock out is
    a denial of service against the one person who needs it;
  - sessions are MAC-checked before anything in them is read, and rotating the
    secret invalidates every one at once, which is how they are revoked.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
mrambossek
2026-08-01 17:12:54 +02:00
co-authored by Claude Fable 5
parent ce6d0c2f64
commit 89a5ff9139
7 changed files with 610 additions and 0 deletions
+253
View File
@@ -0,0 +1,253 @@
// 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:<username>" or "<issuer>#<sub>" 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))
}