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>
204 lines
6.3 KiB
Go
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)
|
|
}
|
|
}
|