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