Files
echolot/server/internal/adminauth/adminauth_test.go
T
mrambossekandClaude Fable 5 89a5ff9139
server-test / test (push) Successful in 44s
adminauth: a break-glass local admin alongside OIDC
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>
2026-08-01 17:12:54 +02:00

204 lines
6.3 KiB
Go

// 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)
}
}