- 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.
265 lines
7.6 KiB
Go
265 lines
7.6 KiB
Go
package auth
|
|
|
|
import (
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
// TestHashPasswordRoundTrip verifies that HashPassword + VerifyPassword works.
|
|
func TestHashPasswordRoundTrip(t *testing.T) {
|
|
params := DefaultArgonParams()
|
|
hash, err := HashPassword("correct-horse-battery-staple", params)
|
|
if err != nil {
|
|
t.Fatalf("HashPassword: %v", err)
|
|
}
|
|
if !strings.HasPrefix(hash, "$argon2id$") {
|
|
t.Errorf("hash does not start with $argon2id$: %q", hash)
|
|
}
|
|
|
|
ok, err := VerifyPassword("correct-horse-battery-staple", hash)
|
|
if err != nil {
|
|
t.Fatalf("VerifyPassword: %v", err)
|
|
}
|
|
if !ok {
|
|
t.Error("VerifyPassword returned false for correct password")
|
|
}
|
|
}
|
|
|
|
// TestHashPasswordWrongPassword verifies that a wrong password is rejected.
|
|
func TestHashPasswordWrongPassword(t *testing.T) {
|
|
params := DefaultArgonParams()
|
|
hash, err := HashPassword("correct-horse", params)
|
|
if err != nil {
|
|
t.Fatalf("HashPassword: %v", err)
|
|
}
|
|
|
|
ok, err := VerifyPassword("wrong-password", hash)
|
|
if err != nil {
|
|
t.Fatalf("VerifyPassword: %v", err)
|
|
}
|
|
if ok {
|
|
t.Error("VerifyPassword returned true for wrong password")
|
|
}
|
|
}
|
|
|
|
// TestHashPasswordUniqueHashes verifies that the same password produces
|
|
// different hashes (due to random salt).
|
|
func TestHashPasswordUniqueHashes(t *testing.T) {
|
|
params := DefaultArgonParams()
|
|
h1, err := HashPassword("password", params)
|
|
if err != nil {
|
|
t.Fatalf("HashPassword (1): %v", err)
|
|
}
|
|
h2, err := HashPassword("password", params)
|
|
if err != nil {
|
|
t.Fatalf("HashPassword (2): %v", err)
|
|
}
|
|
if h1 == h2 {
|
|
t.Error("same password produced identical hashes (salt not random)")
|
|
}
|
|
}
|
|
|
|
// TestHashPasswordEmpty verifies that empty passwords are rejected.
|
|
func TestHashPasswordEmpty(t *testing.T) {
|
|
_, err := HashPassword("", DefaultArgonParams())
|
|
if err == nil {
|
|
t.Error("expected error for empty password, got nil")
|
|
}
|
|
}
|
|
|
|
// TestVerifyPasswordInvalidPHC verifies that malformed PHC strings are rejected.
|
|
func TestVerifyPasswordInvalidPHC(t *testing.T) {
|
|
_, err := VerifyPassword("password", "not-a-phc-string")
|
|
if err == nil {
|
|
t.Error("expected error for invalid PHC string, got nil")
|
|
}
|
|
}
|
|
|
|
// TestVerifyPasswordWrongAlgorithm verifies that non-argon2id PHC strings are
|
|
// rejected.
|
|
func TestVerifyPasswordWrongAlgorithm(t *testing.T) {
|
|
fakeScrypt := "$scrypt$v=1$n=32768,r=8,p=1$c2FsdA$aGFzaA"
|
|
_, err := VerifyPassword("password", fakeScrypt)
|
|
if err == nil {
|
|
t.Error("expected error for non-argon2id PHC string, got nil")
|
|
}
|
|
}
|
|
|
|
// TestValidateTOTP verifies that a correct TOTP code is accepted.
|
|
// This test generates a secret and immediately validates the current code.
|
|
func TestValidateTOTP(t *testing.T) {
|
|
rawSecret, _, err := GenerateTOTPSecret()
|
|
if err != nil {
|
|
t.Fatalf("GenerateTOTPSecret: %v", err)
|
|
}
|
|
|
|
// Compute the expected code for the current time step.
|
|
now := time.Now().Unix()
|
|
code, err := hotp(rawSecret, uint64(now/30)) //nolint:gosec // G115: Unix time is always positive
|
|
if err != nil {
|
|
t.Fatalf("hotp: %v", err)
|
|
}
|
|
|
|
ok, err := ValidateTOTP(rawSecret, code)
|
|
if err != nil {
|
|
t.Fatalf("ValidateTOTP: %v", err)
|
|
}
|
|
if !ok {
|
|
t.Errorf("ValidateTOTP rejected a valid code %q", code)
|
|
}
|
|
}
|
|
|
|
// TestValidateTOTPWrongCode verifies that an incorrect code is rejected.
|
|
func TestValidateTOTPWrongCode(t *testing.T) {
|
|
rawSecret, _, err := GenerateTOTPSecret()
|
|
if err != nil {
|
|
t.Fatalf("GenerateTOTPSecret: %v", err)
|
|
}
|
|
|
|
ok, err := ValidateTOTP(rawSecret, "000000")
|
|
if err != nil {
|
|
t.Fatalf("ValidateTOTP: %v", err)
|
|
}
|
|
// 000000 is very unlikely to be correct; if it is, the test is flaky by
|
|
// chance and should be re-run. The probability is ~3/1000000.
|
|
_ = ok // we cannot assert false without knowing the actual code
|
|
}
|
|
|
|
// TestValidateTOTPWrongLength verifies that codes of wrong length are rejected
|
|
// without an error (they are simply invalid).
|
|
func TestValidateTOTPWrongLength(t *testing.T) {
|
|
rawSecret, _, err := GenerateTOTPSecret()
|
|
if err != nil {
|
|
t.Fatalf("GenerateTOTPSecret: %v", err)
|
|
}
|
|
|
|
for _, code := range []string{"", "12345", "1234567", "abcdef"} {
|
|
ok, err := ValidateTOTP(rawSecret, code)
|
|
if err != nil {
|
|
t.Errorf("ValidateTOTP(%q): unexpected error: %v", code, err)
|
|
}
|
|
if ok && len(code) != 6 {
|
|
t.Errorf("ValidateTOTP accepted wrong-length code %q", code)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestDecodeTOTPSecret verifies base32 decoding with and without padding.
|
|
func TestDecodeTOTPSecret(t *testing.T) {
|
|
// A known base32-encoded 10-byte secret: JBSWY3DPEHPK3PXP (16 chars, padded)
|
|
b32 := "JBSWY3DPEHPK3PXP"
|
|
decoded, err := DecodeTOTPSecret(b32)
|
|
if err != nil {
|
|
t.Fatalf("DecodeTOTPSecret: %v", err)
|
|
}
|
|
if len(decoded) == 0 {
|
|
t.Error("DecodeTOTPSecret returned empty bytes")
|
|
}
|
|
|
|
// Case-insensitive input.
|
|
decoded2, err := DecodeTOTPSecret(strings.ToLower(b32))
|
|
if err != nil {
|
|
t.Fatalf("DecodeTOTPSecret lowercase: %v", err)
|
|
}
|
|
if string(decoded) != string(decoded2) {
|
|
t.Error("case-insensitive decode produced different result")
|
|
}
|
|
}
|
|
|
|
// TestDecodeTOTPSecretInvalid verifies that invalid base32 is rejected.
|
|
func TestDecodeTOTPSecretInvalid(t *testing.T) {
|
|
_, err := DecodeTOTPSecret("not-valid-base32-!@#$%")
|
|
if err == nil {
|
|
t.Error("expected error for invalid base32, got nil")
|
|
}
|
|
}
|
|
|
|
// TestGenerateTOTPSecret verifies that generated secrets are non-empty and
|
|
// unique.
|
|
func TestGenerateTOTPSecret(t *testing.T) {
|
|
raw1, b32_1, err := GenerateTOTPSecret()
|
|
if err != nil {
|
|
t.Fatalf("GenerateTOTPSecret (1): %v", err)
|
|
}
|
|
if len(raw1) != 20 {
|
|
t.Errorf("raw secret length = %d, want 20", len(raw1))
|
|
}
|
|
if b32_1 == "" {
|
|
t.Error("base32 secret is empty")
|
|
}
|
|
|
|
raw2, b32_2, err := GenerateTOTPSecret()
|
|
if err != nil {
|
|
t.Fatalf("GenerateTOTPSecret (2): %v", err)
|
|
}
|
|
if string(raw1) == string(raw2) {
|
|
t.Error("two generated TOTP secrets are identical")
|
|
}
|
|
if b32_1 == b32_2 {
|
|
t.Error("two generated TOTP base32 secrets are identical")
|
|
}
|
|
}
|
|
|
|
// TestDefaultArgonParams verifies that default params meet OWASP minimums.
|
|
func TestDefaultArgonParams(t *testing.T) {
|
|
p := DefaultArgonParams()
|
|
if p.Time < 2 {
|
|
t.Errorf("default Time=%d < OWASP minimum 2", p.Time)
|
|
}
|
|
if p.Memory < 65536 {
|
|
t.Errorf("default Memory=%d KiB < OWASP minimum 64MiB (65536 KiB)", p.Memory)
|
|
}
|
|
if p.Threads < 1 {
|
|
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)
|
|
}
|
|
}
|