Verify login credentials before spending rate-limit budget (closes #150)
All checks were successful
check / check (push) Successful in 2m54s
All checks were successful
check / check (push) Successful in 2m54s
With TRUSTED_PROXIES empty behind the reverse proxy production is required to run behind, every login POST keyed on the proxy's address and shared one 5/minute bucket. A stranger sending five POSTs a minute -- 0.08 requests per second, from anywhere -- kept that bucket permanently full, and the operator's own correct password was answered 429 indefinitely with no second administrative path. The login POST no longer has a pre-emptive limiter. The handler verifies credentials first and spends budget only on a FAILED attempt, so a correct password is never throttled whatever the counters hold. Three things follow, and are implemented together because the first is unsafe without the other two: - Failures are counted per (client bucket, submitted username), five per minute, after which further failures get 429 with a Retry-After. A successful login clears the counter, so mistyping and then succeeding does not leave the operator throttled. - Both key sets are capped at 1024 entries. The submitted username is attacker-controlled, so past the first cap failures fall back to a counter keyed on the client alone, and past both caps a failure is answered as throttled without being recorded. Tracked state stays under half a megabyte and does not grow with invented usernames. - Concurrent Argon2id verifications are capped at two, a 128 MB ceiling at 64 MB per hash. Every password-hashing endpoint takes a slot, including the password-change endpoint, which holds one across both its hashes. A request that waits five seconds without a slot is answered 503 and no hash runs for it. An unknown username is verified against a dummy hash instead of returning early, so a nonexistent account costs the same time as a real one and the response cannot be used to enumerate usernames. The password-change limiter is unchanged: RequireAuth runs ahead of it, so only a request already carrying a valid session reaches its bucket. Also adds the missing test for the third bucketKey call site, where the peer is a trusted proxy but the forwarded chain names no client. Every existing test of that fallback uses an IPv4 proxy, where bucketKey is the identity function, so dropping the /64 masking there left the suite green. README and the TRUSTED_PROXIES startup warning updated: a shared bucket now costs precision, not the availability of the admin path.
This commit is contained in:
@@ -65,3 +65,9 @@ func (r *RetentionReaper) ExportWedgeLoop(
|
||||
func (r *RetentionReaper) ExportSetInterval(d time.Duration) {
|
||||
r.interval = d
|
||||
}
|
||||
|
||||
// DummyPasswordHashForTest exposes the encoded hash that unknown
|
||||
// usernames are verified against.
|
||||
func DummyPasswordHashForTest() string {
|
||||
return dummyPasswordHash()
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"fmt"
|
||||
"math/big"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"golang.org/x/crypto/argon2"
|
||||
)
|
||||
@@ -29,6 +30,10 @@ const hashParts = 6
|
||||
// triggers per-character-class complexity enforcement.
|
||||
const minPasswordComplexityLen = 4
|
||||
|
||||
// dummyPasswordLen is the length of the throwaway password behind
|
||||
// dummyPasswordHash.
|
||||
const dummyPasswordLen = 32
|
||||
|
||||
// Sentinel errors returned by decodeHash.
|
||||
var (
|
||||
errInvalidHashFormat = errors.New("invalid hash format")
|
||||
@@ -122,6 +127,38 @@ func VerifyPassword(
|
||||
return subtle.ConstantTimeCompare(hash, otherHash) == 1, nil
|
||||
}
|
||||
|
||||
// dummyPasswordHash is an encoded Argon2id hash of a random
|
||||
// password, computed once on first use. Nothing can match it: the
|
||||
// password it encodes is discarded as soon as it is hashed. It is
|
||||
// process-wide because building it per request would add a second
|
||||
// 64 MB Argon2id pass to every login for an unknown username.
|
||||
//
|
||||
//nolint:gochecknoglobals // computed once, see above
|
||||
var dummyPasswordHash = sync.OnceValue(func() string {
|
||||
password, err := GenerateRandomPassword(dummyPasswordLen)
|
||||
if err != nil {
|
||||
panic(fmt.Sprintf("generating the dummy password: %v", err))
|
||||
}
|
||||
|
||||
hash, err := HashPassword(password)
|
||||
if err != nil {
|
||||
panic(fmt.Sprintf("hashing the dummy password: %v", err))
|
||||
}
|
||||
|
||||
return hash
|
||||
})
|
||||
|
||||
// VerifyDummyPassword performs a credential verification that cannot
|
||||
// succeed, at the same cost as a real one.
|
||||
//
|
||||
// Login must charge an unknown username the same work as a known
|
||||
// one. Returning early for an account that does not exist answers in
|
||||
// microseconds where a real account takes tens of milliseconds, which
|
||||
// is a username oracle any client can read off the response time.
|
||||
func VerifyDummyPassword(password string) {
|
||||
_, _ = VerifyPassword(password, dummyPasswordHash())
|
||||
}
|
||||
|
||||
// decodeHash extracts parameters, salt, and hash from an
|
||||
// encoded hash string.
|
||||
func decodeHash(
|
||||
|
||||
@@ -191,3 +191,41 @@ func TestHashPasswordUniqueness(t *testing.T) {
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// TestVerifyDummyPassword_DoesRealWork covers the anti-enumeration
|
||||
// path. Login charges an unknown username a verification against a
|
||||
// dummy hash so that a nonexistent account is not answered in
|
||||
// microseconds where a real one takes tens of milliseconds. That only
|
||||
// works if the dummy hash is a real, decodable Argon2id hash: a
|
||||
// malformed one would make VerifyPassword fail on the decode and
|
||||
// return before hashing anything.
|
||||
func TestVerifyDummyPassword_DoesRealWork(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// Runs the OnceValue that builds the dummy hash, so a panic in
|
||||
// it surfaces here rather than on a live login.
|
||||
database.VerifyDummyPassword("whatever was submitted")
|
||||
|
||||
dummy := database.DummyPasswordHashForTest()
|
||||
|
||||
// A hash the verifier cannot decode would make VerifyPassword
|
||||
// return on the decode error, before hashing anything — the
|
||||
// timing oracle this path exists to close.
|
||||
valid, err := database.VerifyPassword("whatever", dummy)
|
||||
if err != nil {
|
||||
t.Fatalf(
|
||||
"the dummy hash must decode like a real one: %v", err,
|
||||
)
|
||||
}
|
||||
|
||||
if valid {
|
||||
t.Error("nothing may authenticate against the dummy hash")
|
||||
}
|
||||
|
||||
if !strings.HasPrefix(dummy, "$argon2id$") {
|
||||
t.Errorf(
|
||||
"the dummy hash must use the same algorithm as real "+
|
||||
"hashes, got %q", dummy,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user