Verify login credentials before spending rate-limit budget (closes #150)
All checks were successful
check / check (push) Successful in 2m46s
All checks were successful
check / check (push) Successful in 2m46s
In the shipped default, any stranger denied the operator the only administrative path at 5 requests per minute: TRUSTED_PROXIES is empty, the README requires a reverse proxy, so every login POST shared one bucket keyed on the proxy. Credentials are now verified first and only a FAILED attempt spends budget, so a correct password is never throttled. Failures are counted per (client bucket, submitted username), bounded. Concurrent Argon2id verifications are capped at two, and the queue for them at 16 — because verifying first lets an attacker force a 64 MB hash per request, and bounding the wait alone bounds nothing. The issue's own recommendation was insufficient and is rejected here: keying by username stops an attacker locking out a DIFFERENT account, but this is a single-admin product with a predictable bootstrap username, so flooding the operator's own name still locks them out. This is speculative — it implements a corrected recommendation ahead of the owner's ruling so the decision can be made by merging or reverting. Three things are disclosed rather than glossed: online guessing rises from 5/min to roughly 27/s, because the 429 is a label on the response and not a gate in front of the hash; the residual exposure is a loss of login AVAILABILITY, not latency, and a determined flood still denies login while it runs, at ~400x the cost and clearing the moment it stops; and the endpoint should be provisioned for ~400 MB resident, not the 203 MB of live commitment it itemises. Independently reviewed four times. Reviewers disproved the suspected FIFO starvation by measurement, then caught two successive memory bounds the code did not have — the second by parking waiters and reading the heap rather than checking the arithmetic.
This commit was merged in pull request #171.
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