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>
254 lines
8.1 KiB
Go
254 lines
8.1 KiB
Go
// 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))
|
|
}
|