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

@@ -24,6 +24,7 @@ import (
"math"
"strconv"
"strings"
"sync"
"time"
"golang.org/x/crypto/argon2"
@@ -54,6 +55,38 @@ func DefaultArgonParams() ArgonParams {
}
}
// dummyHashOnce ensures the dummy hash is computed exactly once at first use.
// Security: computing it lazily (rather than at init time) keeps startup fast;
// using sync.Once makes it safe to call from concurrent goroutines.
var (
dummyHashOnce sync.Once
dummyHashVal string
)
// DummyHash returns a pre-computed Argon2id PHC hash of a fixed dummy password
// using DefaultArgonParams. It is computed once on first call and cached.
//
// Security (F-07): using a real hash with the exact same parameters as
// production password verification ensures that dummy operations (run for
// unknown users or inactive accounts to prevent timing-based enumeration)
// take as long as real verifications, regardless of parameter changes.
// The previous hardcoded string used a 6-byte salt and 6-byte hash which
// was faster to verify than a real 16-byte-salt / 32-byte-hash record.
func DummyHash() string {
dummyHashOnce.Do(func() {
h, err := HashPassword("dummy-password-for-timing-only", DefaultArgonParams())
if err != nil {
// This should be unreachable in production — HashPassword only fails
// if crypto/rand fails or the password is empty, neither of which
// applies here. Panic so the misconfiguration surfaces immediately
// rather than silently degrading security.
panic("auth: DummyHash: failed to pre-compute dummy hash: " + err.Error())
}
dummyHashVal = h
})
return dummyHashVal
}
// HashPassword hashes a password using Argon2id and returns a PHC-format string.
// A random 16-byte salt is generated via crypto/rand for each call.
//