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

@@ -174,7 +174,7 @@ func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) {
if err != nil {
// Security: return a generic error whether the user exists or not.
// Always run a dummy Argon2 check to prevent timing-based user enumeration.
_, _ = auth.VerifyPassword("dummy", "$argon2id$v=19$m=65536,t=3,p=4$dGVzdHNhbHQ$dGVzdGhhc2g")
_, _ = auth.VerifyPassword("dummy", auth.DummyHash())
s.writeAudit(r, model.EventLoginFail, nil, nil, fmt.Sprintf(`{"username":%q,"reason":"unknown_user"}`, req.Username))
middleware.WriteError(w, http.StatusUnauthorized, "invalid credentials", "unauthorized")
return
@@ -183,7 +183,7 @@ func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) {
// Security: Check account status before credential verification to avoid
// leaking whether the account exists based on timing differences.
if acct.Status != model.AccountStatusActive {
_, _ = auth.VerifyPassword("dummy", "$argon2id$v=19$m=65536,t=3,p=4$dGVzdHNhbHQ$dGVzdGhhc2g")
_, _ = auth.VerifyPassword("dummy", auth.DummyHash())
s.writeAudit(r, model.EventLoginFail, &acct.ID, nil, `{"reason":"account_inactive"}`)
middleware.WriteError(w, http.StatusUnauthorized, "invalid credentials", "unauthorized")
return