diff --git a/server/cmd/echolot-server/main.go b/server/cmd/echolot-server/main.go index 11d2030..6f859cd 100644 --- a/server/cmd/echolot-server/main.go +++ b/server/cmd/echolot-server/main.go @@ -11,6 +11,7 @@ package main import ( + "bufio" "context" "crypto/ecdsa" "crypto/elliptic" @@ -36,6 +37,7 @@ import ( "syscall" "time" + "echo-lot.app/server/internal/adminauth" "echo-lot.app/server/internal/canarydns" "echo-lot.app/server/internal/compat" "echo-lot.app/server/internal/config" @@ -80,6 +82,8 @@ func run() error { return system.InstallSystemd(cfg.SelfUpdateAPI) case actions.UninstallSystemd: return system.UninstallSystemd() + case actions.SetAdminPassword: + return setAdminPassword(cfg) case actions.SelfUpdate: return selfupdate.Run(cfg.SelfUpdateAPI, Version) } @@ -516,3 +520,48 @@ func publicControlURL(cfg *config.Config) string { } return "https://" + addr } + +// setAdminPassword stores the break-glass admin credential. +// +// The password is read from stdin rather than taken as a flag, so it never lands in shell +// history, in the process list where any local user can see it, or in a systemd unit. Piping is +// still possible for automation: +// +// printf '%s' "$PW" | echolot-server --set-admin-password --admin-user ops +func setAdminPassword(cfg *config.Config) error { + st, err := store.Open(cfg.StateDir) + if err != nil { + return fmt.Errorf("state store: %w", err) + } + fmt.Fprintf(os.Stderr, "New password for %q (input is not echoed if this is a terminal): ", cfg.AdminUser) + pw, err := readSecret() + if err != nil { + return err + } + fmt.Fprintln(os.Stderr) + + cred, err := adminauth.NewCredential(cfg.AdminUser, pw) + if err != nil { + return err + } + if err := st.SetLocalAdmin(cred); err != nil { + return err + } + fmt.Fprintf(os.Stderr, "Break-glass admin %q set. This account works even when the identity\n"+ + "provider does not, which is the point of it — treat the password accordingly.\n", cfg.AdminUser) + return nil +} + +// readSecret reads one line from stdin, without echo where the terminal allows it. +func readSecret() (string, error) { + restore, _ := system.DisableEcho(os.Stdin) + if restore != nil { + defer restore() + } + r := bufio.NewReader(os.Stdin) + line, err := r.ReadString('\n') + if err != nil && line == "" { + return "", err + } + return strings.TrimSpace(line), nil +} diff --git a/server/internal/adminauth/adminauth.go b/server/internal/adminauth/adminauth.go new file mode 100644 index 0000000..9f06e8c --- /dev/null +++ b/server/internal/adminauth/adminauth.go @@ -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:" 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)) +} diff --git a/server/internal/adminauth/adminauth_test.go b/server/internal/adminauth/adminauth_test.go new file mode 100644 index 0000000..845870a --- /dev/null +++ b/server/internal/adminauth/adminauth_test.go @@ -0,0 +1,203 @@ +// SPDX-FileCopyrightText: 2026 Echolot contributors +// SPDX-License-Identifier: GPL-3.0-or-later + +package adminauth + +import ( + "strings" + "testing" + "time" +) + +// PBKDF2 at 600k iterations is slow on purpose, so these use a reduced count where the test is +// about logic rather than cost. +func fastCredential(t *testing.T, user, pass string) Credential { + t.Helper() + c, err := NewCredential(user, pass) + if err != nil { + t.Fatal(err) + } + return c +} + +func TestVerifyAcceptsOnlyTheRightPair(t *testing.T) { + c := fastCredential(t, "admin", "correct-horse-battery") + + if !c.Verify("admin", "correct-horse-battery") { + t.Fatal("the correct credentials were rejected") + } + for _, tc := range []struct{ user, pass string }{ + {"admin", "wrong-password-here"}, + {"admin", ""}, + {"root", "correct-horse-battery"}, + {"", "correct-horse-battery"}, + {"ADMIN", "correct-horse-battery"}, // usernames are not case-folded + } { + if c.Verify(tc.user, tc.pass) { + t.Errorf("accepted %q/%q", tc.user, tc.pass) + } + } +} + +// Two credentials with the same password must not share a hash, or one cracked password reveals +// every reuse of it and a precomputed table works against all of them. +func TestSaltsDiffer(t *testing.T) { + a := fastCredential(t, "admin", "the-same-password-x") + b := fastCredential(t, "admin", "the-same-password-x") + if a.Salt == b.Salt { + t.Fatal("two credentials share a salt") + } + if a.Hash == b.Hash { + t.Fatal("the same password produced the same hash twice") + } + // Both must still verify — a salt that is not actually used would also produce differing + // hashes if it were mixed in wrongly. + if !a.Verify("admin", "the-same-password-x") || !b.Verify("admin", "the-same-password-x") { + t.Fatal("a salted credential does not verify") + } +} + +// The stored iteration count is used rather than the current constant, so raising the constant +// later does not silently lock out every existing password. +func TestOldIterationCountsStillVerify(t *testing.T) { + c := fastCredential(t, "admin", "a-perfectly-fine-pw") + c.Iterations = iterations // as stored + if !c.Verify("admin", "a-perfectly-fine-pw") { + t.Fatal("credential does not verify with its stored iteration count") + } + // A credential written before the field existed must not be treated as zero-iteration. + c.Iterations = 0 + if !c.Verify("admin", "a-perfectly-fine-pw") { + t.Fatal("a credential with no recorded iteration count failed to verify") + } +} + +func TestWeakInputsAreRefusedAtCreation(t *testing.T) { + if _, err := NewCredential("", "long-enough-password"); err == nil { + t.Error("an empty username was accepted") + } + if _, err := NewCredential("admin", "short"); err == nil { + t.Error("a short password was accepted") + } +} + +func TestAnEmptyCredentialNeverVerifies(t *testing.T) { + var zero Credential + if zero.Verify("", "") { + t.Fatal("a server with no local admin configured accepted empty credentials") + } + if zero.Verify("admin", "anything") { + t.Fatal("an unset credential verified") + } +} + +// ---- sessions ---------------------------------------------------------------------------- + +func TestSessionRoundTrip(t *testing.T) { + secret, _ := NewSecret() + s := NewSessions(secret, time.Hour) + got, err := s.Parse(s.Issue("local:admin", "Admin")) + if err != nil { + t.Fatal(err) + } + if got.Subject != "local:admin" || got.Display != "Admin" { + t.Fatalf("session did not round-trip: %+v", got) + } +} + +// The cookie carries its own contents, so the MAC is the only thing standing between a user and +// promoting themselves. Every tampered form must fail. +func TestTamperedSessionsAreRejected(t *testing.T) { + secret, _ := NewSecret() + s := NewSessions(secret, time.Hour) + good := s.Issue("local:admin", "Admin") + + parts := strings.Split(good, ".") + tampered := []string{ + "", + "garbage", + good + "x", // signature altered + strings.Replace(good, parts[0], "Zm9v", 1), // subject swapped + strings.Join(parts[:len(parts)-1], "."), // signature removed + parts[0] + "." + parts[1] + "." + parts[2], // signature removed, well-formed payload + } + for _, v := range tampered { + if _, err := s.Parse(v); err == nil { + t.Errorf("accepted a tampered session: %q", v) + } + } +} + +func TestSessionsFromAnotherSecretAreRejected(t *testing.T) { + a, _ := NewSecret() + b, _ := NewSecret() + issued := NewSessions(a, time.Hour).Issue("local:admin", "Admin") + if _, err := NewSessions(b, time.Hour).Parse(issued); err == nil { + t.Fatal("a session signed with a different secret was accepted — rotating the secret " + + "must invalidate every existing session") + } +} + +func TestExpiredSessionsAreRejected(t *testing.T) { + secret, _ := NewSecret() + // A negative TTL is not reachable through NewSessions, so issue with a real one and check + // the boundary via a session that has already run out. + s := NewSessions(secret, time.Millisecond) + v := s.Issue("local:admin", "Admin") + time.Sleep(10 * time.Millisecond) + if _, err := s.Parse(v); err == nil { + t.Fatal("an expired session was accepted") + } +} + +// ---- throttle ------------------------------------------------------------------------------ + +func TestThrottleGrowsWithFailuresAndResetsOnSuccess(t *testing.T) { + tr := NewThrottle() + if d := tr.Delay(); d != 0 { + t.Fatalf("a first attempt was delayed by %v", d) + } + for i := 0; i < 2; i++ { + tr.Failed() + } + first := tr.Delay() + for i := 0; i < 6; i++ { + tr.Failed() + } + later := tr.Delay() + if !(later > first && first > 0) { + t.Fatalf("delay did not grow with failures: %v then %v", first, later) + } + tr.Succeeded() + if d := tr.Delay(); d != 0 { + t.Fatalf("a successful login did not clear the throttle: %v", d) + } +} + +// A break-glass credential that an attacker can lock out is a denial of service against the one +// person who needs it. The delay must stay bounded rather than becoming a lockout. +func TestThrottleNeverLocksOutPermanently(t *testing.T) { + tr := NewThrottle() + for i := 0; i < 1000; i++ { + tr.Failed() + } + if d := tr.Delay(); d > 10*time.Second { + t.Fatalf("throttle became a lockout: %v", d) + } +} + +func TestThrottleForgivesAfterAQuietPeriod(t *testing.T) { + tr := NewThrottle() + now := time.Now() + tr.now = func() time.Time { return now } + for i := 0; i < 10; i++ { + tr.Failed() + } + if tr.Delay() == 0 { + t.Fatal("failures did not register") + } + now = now.Add(2 * time.Minute) + if d := tr.Delay(); d != 0 { + t.Fatalf("an operator returning later was still throttled: %v", d) + } +} diff --git a/server/internal/config/config.go b/server/internal/config/config.go index 228307f..c2aee05 100644 --- a/server/internal/config/config.go +++ b/server/internal/config/config.go @@ -73,6 +73,9 @@ type Config struct { OIDCClientID string // ECHOLOT_OIDC_CLIENT_ID / --oidc-client-id OIDCAdminGroup string // ECHOLOT_OIDC_ADMIN_GROUP / --oidc-admin-group + // Break-glass admin username; the password lives hashed in the state store. + AdminUser string // ECHOLOT_ADMIN_USER / --admin-user + // Mode Docker bool // --docker (or autodetected; env ECHOLOT_DOCKER=1 forces) } @@ -131,6 +134,9 @@ func Load(args []string) (*Config, *Actions, error) { fs.BoolVar(&a.InstallSystemd, "install-systemd", false, "install a systemd unit for this binary and exit") fs.BoolVar(&a.UninstallSystemd, "uninstall-systemd", false, "remove the systemd unit and exit") + fs.BoolVar(&a.SetAdminPassword, "set-admin-password", false, + "set the break-glass admin password (username as --admin-user, password read from stdin) and exit") + fs.StringVar(&c.AdminUser, "admin-user", envOr("ADMIN_USER", "admin"), "username for the break-glass admin") fs.BoolVar(&a.SelfUpdate, "self-update", false, "check for a newer release, replace this binary, and exit") fs.BoolVar(&a.Version, "version", false, "print version and exit") @@ -165,6 +171,7 @@ type Actions struct { InstallSystemd bool UninstallSystemd bool SelfUpdate bool + SetAdminPassword bool Version bool } diff --git a/server/internal/store/store.go b/server/internal/store/store.go index e25bf0a..58dc9e3 100644 --- a/server/internal/store/store.go +++ b/server/internal/store/store.go @@ -16,6 +16,8 @@ import ( "os" "path/filepath" "sync" + + "echo-lot.app/server/internal/adminauth" "time" ) @@ -59,6 +61,12 @@ type Store struct { type fileData struct { Tokens []EnrollToken `json:"tokens"` Devices []Device `json:"devices"` + // The break-glass admin. Absent until an operator sets one. + LocalAdmin *adminauth.Credential `json:"local_admin,omitempty"` + // Signing secret for admin session cookies. Persisted so sessions survive a restart; + // deleting it from the state file invalidates every session at once, which is how an + // operator revokes them. + SessionSecret string `json:"session_secret,omitempty"` } func Open(stateDir string) (*Store, error) { @@ -177,6 +185,50 @@ func (s *Store) DeleteDevice(id string) error { return errors.New("no such device") } +// SetLocalAdmin stores (or replaces) the break-glass admin password. +func (s *Store) SetLocalAdmin(c adminauth.Credential) error { + s.mu.Lock() + defer s.mu.Unlock() + s.data.LocalAdmin = &c + return s.save() +} + +// LocalAdmin returns the configured break-glass admin, or nil. +func (s *Store) LocalAdmin() *adminauth.Credential { + s.mu.Lock() + defer s.mu.Unlock() + if s.data.LocalAdmin == nil { + return nil + } + c := *s.data.LocalAdmin + return &c +} + +// ClearLocalAdmin removes the break-glass admin. +func (s *Store) ClearLocalAdmin() error { + s.mu.Lock() + defer s.mu.Unlock() + s.data.LocalAdmin = nil + return s.save() +} + +// SessionSecret returns the admin session signing secret, creating one on first use. +func (s *Store) SessionSecret() ([]byte, error) { + s.mu.Lock() + defer s.mu.Unlock() + if s.data.SessionSecret != "" { + if b, err := hex.DecodeString(s.data.SessionSecret); err == nil && len(b) >= 32 { + return b, nil + } + } + b, err := adminauth.NewSecret() + if err != nil { + return nil, err + } + s.data.SessionSecret = hex.EncodeToString(b) + return b, s.save() +} + func (s *Store) DeviceByCredential(cred string) *Device { s.mu.Lock() defer s.mu.Unlock() diff --git a/server/internal/system/echo_linux.go b/server/internal/system/echo_linux.go new file mode 100644 index 0000000..13624da --- /dev/null +++ b/server/internal/system/echo_linux.go @@ -0,0 +1,34 @@ +// SPDX-FileCopyrightText: 2026 Echolot contributors +// SPDX-License-Identifier: GPL-3.0-or-later + +//go:build linux + +package system + +import ( + "os" + "syscall" + "unsafe" +) + +// DisableEcho turns off terminal echo while a password is typed, returning a function that puts +// the terminal back. Both are best-effort: when stdin is a pipe (the automation case) there is +// no terminal to change and nothing to restore. +func DisableEcho(f *os.File) (func(), error) { + fd := f.Fd() + var t syscall.Termios + if _, _, errno := syscall.Syscall6(syscall.SYS_IOCTL, fd, + syscall.TCGETS, uintptr(unsafe.Pointer(&t)), 0, 0, 0); errno != 0 { + return nil, errno // not a terminal; nothing to do + } + original := t + t.Lflag &^= syscall.ECHO + if _, _, errno := syscall.Syscall6(syscall.SYS_IOCTL, fd, + syscall.TCSETS, uintptr(unsafe.Pointer(&t)), 0, 0, 0); errno != 0 { + return nil, errno + } + return func() { + _, _, _ = syscall.Syscall6(syscall.SYS_IOCTL, fd, + syscall.TCSETS, uintptr(unsafe.Pointer(&original)), 0, 0, 0) + }, nil +} diff --git a/server/internal/system/echo_other.go b/server/internal/system/echo_other.go new file mode 100644 index 0000000..bb09a03 --- /dev/null +++ b/server/internal/system/echo_other.go @@ -0,0 +1,12 @@ +// SPDX-FileCopyrightText: 2026 Echolot contributors +// SPDX-License-Identifier: GPL-3.0-or-later + +//go:build !linux + +package system + +import "os" + +// DisableEcho is a no-op off Linux: the password is still read, just echoed. Better than +// refusing to run — an operator on a Mac still needs to set the break-glass password. +func DisableEcho(*os.File) (func(), error) { return nil, nil }