Signing in and being allowed to administer the server were the same question: the OIDC callback refused a session outright to anyone outside the admin group. A legitimate user could authenticate, be told what they could not do, and be left with no way to see or delete the data their own devices had uploaded. They are separate questions now. Everyone who authenticates gets a session; the admin flag rides inside the MAC'd payload, so promoting yourself means forging a signature rather than editing a cookie, and a role that does not parse fails closed to "user". Pages scope themselves through visibleDevices/mayTouchRun rather than filtering individually — per-page scoping is what the next page added will be missing, and that failure is silent, since a listing that leaks other people's uploads looks exactly like one that does not. Someone else's run answers 404, not 403: a distinguishable refusal would confirm the run exists. Revoking devices and minting enrolment tokens affect the whole server and stay behind adminOnly at the route table, where someone looking for who-may-do-what will actually find it. Ownership is re-read per request instead of captured at sign-in, so unlinking an account takes effect immediately rather than at session expiry. Tests cover that, plus the degenerate case of an empty subject, which must own nothing rather than everything with an empty account id. Also: attribute the ICMPv6 finding per network. It compared "is IPv6 configured anywhere on this device" against "did any network answer", which on a phone reports IPv6-is-broken about a network where IPv6 was never configured. network_ref is null on every test, so the probe now records per-network outcomes structurally rather than as prose a finding would have to parse. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
273 lines
9.0 KiB
Go
273 lines
9.0 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 account, however it proved itself. Not necessarily an admin:
|
|
// signing in and being allowed to administer the server are separate questions, and a plain user
|
|
// gets a session so they can manage their own uploads.
|
|
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
|
|
// Admin is authorisation, decided at sign-in and carried inside the signed payload.
|
|
//
|
|
// Inside, specifically — not derived later from the subject, and not stored beside the MAC.
|
|
// A flag outside the signature is a privilege escalation anyone can perform with a text
|
|
// editor, and re-deriving it per request would mean re-reading group membership from the IdP
|
|
// on a path that has no token to do it with.
|
|
Admin bool
|
|
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 account.
|
|
func (s *Sessions) Issue(subject, display string, admin bool) string {
|
|
exp := time.Now().Add(s.ttl).Unix()
|
|
role := "u"
|
|
if admin {
|
|
role = "a"
|
|
}
|
|
payload := base64.RawURLEncoding.EncodeToString([]byte(subject)) + "." +
|
|
base64.RawURLEncoding.EncodeToString([]byte(display)) + "." +
|
|
strconv.FormatInt(exp, 10) + "." + role
|
|
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) != 4 {
|
|
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)
|
|
}
|
|
// Anything that is not exactly the admin marker is a user. A malformed role must fail closed:
|
|
// the safe reading of an unparseable privilege claim is the smaller privilege.
|
|
admin := parts[3] == "a"
|
|
return &Session{
|
|
Subject: string(subject), Display: string(display),
|
|
Admin: admin, 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))
|
|
}
|