mirror of
https://github.com/MeshTender/MeshTender.git
synced 2026-09-02 01:38:17 +00:00
131 lines
4.9 KiB
Go
131 lines
4.9 KiB
Go
package auth
|
|
|
|
import (
|
|
"sort"
|
|
"testing"
|
|
"time"
|
|
|
|
"golang.org/x/crypto/bcrypt"
|
|
)
|
|
|
|
// TestDummyPasswordHashMatchesStoredHashes pins the two structural properties that
|
|
// make spendPasswordWork indistinguishable from a real verification: the dummy hash
|
|
// costs the same bcrypt rounds as a hash the app actually stores, and no password
|
|
// can accidentally match it.
|
|
//
|
|
// Cost is asserted against a *freshly produced* hash rather than the literal
|
|
// bcrypt.DefaultCost, so the two can never drift apart: if hashPassword ever moves
|
|
// off DefaultCost, the dummy follows it and this test keeps passing for the right
|
|
// reason.
|
|
func TestDummyPasswordHashMatchesStoredHashes(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
real, err := hashPassword("a real user's password")
|
|
if err != nil {
|
|
t.Fatalf("hashPassword: %v", err)
|
|
}
|
|
realCost, err := bcrypt.Cost([]byte(real))
|
|
if err != nil {
|
|
t.Fatalf("bcrypt.Cost(real): %v", err)
|
|
}
|
|
dummyCost, err := bcrypt.Cost([]byte(dummyPasswordHash))
|
|
if err != nil {
|
|
t.Fatalf("bcrypt.Cost(dummy): %v — is it a valid bcrypt hash?", err)
|
|
}
|
|
if dummyCost != realCost {
|
|
t.Errorf("dummy hash cost = %d, stored hash cost = %d; the work must match",
|
|
dummyCost, realCost)
|
|
}
|
|
|
|
// Whatever a caller submits, it must not verify.
|
|
for _, guess := range []string{"", "password", "a real user's password", dummyPasswordHash} {
|
|
if ok, _ := comparePassword(dummyPasswordHash, guess); ok {
|
|
t.Errorf("dummy hash matched %q — it must never verify", guess)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestSpendPasswordWorkDoesTwoComparisons proves — deterministically, with no
|
|
// timing involved — that the no-stored-hash path burns the same number of bcrypt
|
|
// operations as a real failed verification.
|
|
//
|
|
// The proof rests on comparePassword's return shape. It tries the pre-hash scheme
|
|
// first, then the legacy raw scheme, and its results are distinguishable:
|
|
//
|
|
// (true, false) → matched on the first try → 1 bcrypt op
|
|
// (true, true) → matched on the second try → 2 bcrypt ops
|
|
// (false, false) → matched neither → 2 bcrypt ops
|
|
//
|
|
// So observing (false, false) for both the dummy hash and a real hash with a wrong
|
|
// password establishes that both cost exactly two operations. That's the property
|
|
// that matters, and unlike a duration comparison it cannot flake.
|
|
func TestSpendPasswordWorkDoesTwoComparisons(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
real, err := hashPassword("stored password")
|
|
if err != nil {
|
|
t.Fatalf("hashPassword: %v", err)
|
|
}
|
|
|
|
// The reference: an existing user typing the wrong password.
|
|
ok, legacy := comparePassword(real, "wrong password")
|
|
if ok || legacy {
|
|
t.Fatalf("real hash + wrong password = (%v, %v), want (false, false)", ok, legacy)
|
|
}
|
|
|
|
// The path taken when there is no user, or the user has no password. Same shape
|
|
// ⇒ same number of bcrypt operations.
|
|
ok, legacy = comparePassword(dummyPasswordHash, "wrong password")
|
|
if ok || legacy {
|
|
t.Fatalf("dummy hash = (%v, %v), want (false, false) — the no-hash path must "+
|
|
"try both schemes, like a real failed check", ok, legacy)
|
|
}
|
|
}
|
|
|
|
// TestSpendPasswordWorkActuallyCostsTime is the belt-and-braces companion to the
|
|
// structural test above: it confirms real bcrypt work happens, catching a change
|
|
// that made spendPasswordWork a no-op (or stubbed comparePassword) while keeping
|
|
// its signature.
|
|
//
|
|
// Deliberately one-sided and calibrated. It measures a genuine failed comparison on
|
|
// this machine and requires the dummy path to cost a comparable amount, rather than
|
|
// hardcoding milliseconds that would be wrong on other hardware. The realistic
|
|
// regressions are dramatic — removing the call drops ~100ms to ~1µs (a factor of
|
|
// 10^5) — so a loose floor catches them with no flake risk. Medians damp scheduler
|
|
// noise.
|
|
func TestSpendPasswordWorkActuallyCostsTime(t *testing.T) {
|
|
// No t.Parallel: this measures wall-clock, so it shouldn't share a core with the
|
|
// package's other bcrypt-heavy tests.
|
|
const samples = 5
|
|
|
|
real, err := hashPassword("stored password")
|
|
if err != nil {
|
|
t.Fatalf("hashPassword: %v", err)
|
|
}
|
|
|
|
medianOf := func(f func()) time.Duration {
|
|
times := make([]time.Duration, samples)
|
|
for i := range times {
|
|
start := time.Now()
|
|
f()
|
|
times[i] = time.Since(start)
|
|
}
|
|
sort.Slice(times, func(i, j int) bool { return times[i] < times[j] })
|
|
return times[samples/2]
|
|
}
|
|
|
|
reference := medianOf(func() { _, _ = comparePassword(real, "wrong password") })
|
|
dummy := medianOf(func() { spendPasswordWork("wrong password") })
|
|
|
|
// Guard against a machine so fast the measurement is meaningless.
|
|
if reference < 100*time.Microsecond {
|
|
t.Skipf("bcrypt comparison too fast to measure reliably (%v)", reference)
|
|
}
|
|
if ratio := float64(dummy) / float64(reference); ratio < 0.5 {
|
|
t.Errorf("spendPasswordWork took %v vs a real failed comparison's %v (%.3fx) — "+
|
|
"the no-hash path isn't doing the work, so response time reveals whether "+
|
|
"the account exists", dummy, reference, ratio)
|
|
}
|
|
t.Logf("real failed comparison %v, dummy path %v", reference, dummy)
|
|
}
|