Fix F-07: pre-compute real Argon2 dummy hash via sync.Once

- auth/auth.go: add DummyHash() which uses sync.Once to compute
  HashPassword("dummy-password-for-timing-only", DefaultArgonParams())
  on first call; subsequent calls return the cached PHC string;
  add sync to imports
- auth/auth_test.go: TestDummyHashIsValidPHC verifies the hash
  parses and verifies correctly; TestDummyHashIsCached verifies
  sync.Once behaviour; TestDummyHashMatchesDefaultParams verifies
  embedded m/t/p match DefaultArgonParams()
- server/server.go, grpcserver/auth.go, ui/ui.go: replace five
  hardcoded PHC strings with auth.DummyHash() calls
- AUDIT.md: mark F-07 as fixed
Security: the previous hardcoded hash used a 6-byte salt and
  6-byte output ("testsalt"/"testhash" in base64), which Argon2id
  verifies faster than a real 16-byte-salt / 32-byte-output hash.
  This timing gap was measurable and could aid user enumeration.
  auth.DummyHash() uses identical parameters and full-length salt
  and output, so dummy verification timing matches real timing
  exactly, regardless of future parameter changes.
This commit is contained in:
2026-03-11 20:37:27 -07:00
parent 005e734842
commit 6e690c4435
6 changed files with 91 additions and 11 deletions

View File

@@ -214,3 +214,51 @@ func TestDefaultArgonParams(t *testing.T) {
t.Errorf("default Threads=%d < 1", p.Threads)
}
}
// TestDummyHashIsValidPHC verifies DummyHash returns a non-empty string that
// is a valid Argon2id PHC hash verifiable by VerifyPassword.
func TestDummyHashIsValidPHC(t *testing.T) {
h := DummyHash()
if h == "" {
t.Fatal("DummyHash returned empty string")
}
// Must parse as a valid PHC string with OWASP-compatible parameters.
// VerifyPassword with the correct password must succeed.
ok, err := VerifyPassword("dummy-password-for-timing-only", h)
if err != nil {
t.Fatalf("VerifyPassword against DummyHash: %v", err)
}
if !ok {
t.Error("VerifyPassword against DummyHash returned false (hash mismatch)")
}
}
// TestDummyHashIsCached verifies that consecutive calls return the same string
// (sync.Once caching).
func TestDummyHashIsCached(t *testing.T) {
h1 := DummyHash()
h2 := DummyHash()
if h1 != h2 {
t.Errorf("DummyHash returned different values on consecutive calls:\n first: %q\n second: %q", h1, h2)
}
}
// TestDummyHashMatchesDefaultParams verifies the embedded parameters in the
// returned PHC string match DefaultArgonParams (m, t, p).
func TestDummyHashMatchesDefaultParams(t *testing.T) {
h := DummyHash()
params, _, _, err := parsePHC(h)
if err != nil {
t.Fatalf("parsePHC(DummyHash()): %v", err)
}
def := DefaultArgonParams()
if params.Memory != def.Memory {
t.Errorf("Memory = %d, want %d", params.Memory, def.Memory)
}
if params.Time != def.Time {
t.Errorf("Time = %d, want %d", params.Time, def.Time)
}
if params.Threads != def.Threads {
t.Errorf("Threads = %d, want %d", params.Threads, def.Threads)
}
}