package auth import ( "strings" "testing" ) // testParams runs argon2id at a cost that is still real but does not make the // suite crawl. Every property under test — salting, verification, the encoded // format — is independent of the cost, and DefaultPasswordParams is asserted // separately in TestDefaultPasswordParamsMeetOWASP. var testParams = PasswordParams{Memory: 8 * 1024, Time: 1, Threads: 2, SaltLength: 16, KeyLength: 32} const goodPassword = "correct-horse-battery-staple" // 1. Hash generation produces a well-formed, self-describing argon2id record. func TestHashPasswordProducesArgon2idRecord(t *testing.T) { hash, err := HashPasswordWithParams(goodPassword, testParams) if err != nil { t.Fatalf("HashPasswordWithParams: %v", err) } if !strings.HasPrefix(hash, "$argon2id$") { t.Fatalf("hash is not argon2id: %q", firstField(hash)) } // bcrypt would be "$2a$"/"$2b$"; argon2i and argon2d are different KDFs. // The decision was argon2id specifically, so assert it rather than merely // "some hash was produced". if parts := strings.Split(hash, "$"); len(parts) != 6 { t.Fatalf("hash has %d fields, want 6 (PHC format)", len(parts)) } got, salt, key, err := DecodePasswordHash(hash) if err != nil { t.Fatalf("DecodePasswordHash: %v", err) } if got.Memory != testParams.Memory || got.Time != testParams.Time || got.Threads != testParams.Threads { t.Errorf("decoded params = m=%d,t=%d,p=%d, want m=%d,t=%d,p=%d", got.Memory, got.Time, got.Threads, testParams.Memory, testParams.Time, testParams.Threads) } if len(salt) != int(testParams.SaltLength) { t.Errorf("salt is %d bytes, want %d", len(salt), testParams.SaltLength) } if len(key) != int(testParams.KeyLength) { t.Errorf("key is %d bytes, want %d", len(key), testParams.KeyLength) } // The whole point of the format: the hash must not contain the password. if strings.Contains(hash, goodPassword) { t.Error("the encoded hash contains the plaintext password") } } // 2. The correct password verifies. func TestVerifyPasswordAcceptsTheCorrectPassword(t *testing.T) { hash, err := HashPasswordWithParams(goodPassword, testParams) if err != nil { t.Fatalf("hash: %v", err) } ok, err := VerifyPassword(hash, goodPassword) if err != nil { t.Fatalf("VerifyPassword: %v", err) } if !ok { t.Fatal("the correct password did not verify") } } // 3. An incorrect password is rejected — including the near misses that a // sloppy comparison would let through. func TestVerifyPasswordRejectsIncorrectPasswords(t *testing.T) { hash, err := HashPasswordWithParams(goodPassword, testParams) if err != nil { t.Fatalf("hash: %v", err) } wrong := map[string]string{ "different": "incorrect-horse-battery-staple", "empty": "", "prefix": goodPassword[:len(goodPassword)-1], "suffix appended": goodPassword + "x", "case flipped": strings.ToUpper(goodPassword), "whitespace": " " + goodPassword, } for name, candidate := range wrong { t.Run(name, func(t *testing.T) { ok, err := VerifyPassword(hash, candidate) if err != nil { t.Fatalf("VerifyPassword returned an error for a wrong password: %v", err) } if ok { t.Error("a wrong password verified") } }) } } // 4. Different passwords produce different hashes — and so does the SAME // password hashed twice, which is the stronger property and the one that // actually depends on the salt being random. func TestHashPasswordIsSaltedPerCall(t *testing.T) { a, err := HashPasswordWithParams(goodPassword, testParams) if err != nil { t.Fatalf("hash a: %v", err) } b, err := HashPasswordWithParams(goodPassword, testParams) if err != nil { t.Fatalf("hash b: %v", err) } if a == b { t.Fatal("hashing the same password twice produced identical hashes; the salt is not random") } // Both must still verify: a per-call salt is only useful if it travels // with the hash. for i, h := range []string{a, b} { ok, err := VerifyPassword(h, goodPassword) if err != nil || !ok { t.Fatalf("hash %d did not verify its own password (ok=%v err=%v)", i, ok, err) } } c, err := HashPasswordWithParams("a-completely-different-password", testParams) if err != nil { t.Fatalf("hash c: %v", err) } if c == a { t.Error("different passwords produced identical hashes") } // And a hash must not verify a password it was not made from. if ok, _ := VerifyPassword(c, goodPassword); ok { t.Error("a hash verified a password it was not derived from") } } // 5a. Empty and out-of-policy passwords are refused at hashing time. func TestHashPasswordRejectsInvalidPasswords(t *testing.T) { cases := map[string]struct { password string want error }{ "empty": {"", ErrEmptyPassword}, "too short": {strings.Repeat("a", MinPasswordLength-1), ErrPasswordTooShort}, "too long": {strings.Repeat("a", MaxPasswordLength+1), ErrPasswordTooLong}, } for name, tc := range cases { t.Run(name, func(t *testing.T) { hash, err := HashPasswordWithParams(tc.password, testParams) if err != tc.want { t.Fatalf("error = %v, want %v", err, tc.want) } if hash != "" { t.Error("a hash was returned alongside the error") } // The rejection must not quote the input back. if tc.password != "" && err != nil && strings.Contains(err.Error(), tc.password) { t.Error("the error message contains the password") } }) } // The boundary itself is allowed: the rule is "at least MinPasswordLength". if _, err := HashPasswordWithParams(strings.Repeat("a", MinPasswordLength), testParams); err != nil { t.Errorf("a password of exactly the minimum length was rejected: %v", err) } } // 5b. A malformed *stored* hash is an error, not a silent "wrong password". // The distinction matters: one is a 401, the other is a 500. func TestVerifyPasswordRejectsMalformedHashes(t *testing.T) { valid, err := HashPasswordWithParams(goodPassword, testParams) if err != nil { t.Fatalf("hash: %v", err) } fields := strings.Split(valid, "$") bad := map[string]string{ "empty": "", "not a phc string": "not-a-hash", "bcrypt": "$2a$10$N9qo8uLOickgx2ZMRZoMyeIjZAgcfl7p92ldGxad68LJZdL17lhWy", "argon2i": strings.Replace(valid, "argon2id", "argon2i", 1), "too few fields": strings.Join(fields[:5], "$"), "unparseable params": strings.Replace(valid, fields[3], "m=x,t=y,p=z", 1), "zero memory": strings.Replace(valid, fields[3], "m=0,t=1,p=2", 1), "bad version": strings.Replace(valid, fields[2], "v=notanumber", 1), "salt not base64": strings.Replace(valid, fields[4], "!!!!not base64!!!!", 1), } for name, encoded := range bad { t.Run(name, func(t *testing.T) { ok, err := VerifyPassword(encoded, goodPassword) if err == nil { t.Fatal("a malformed hash verified without an error") } if ok { t.Error("a malformed hash reported a successful verification") } }) } // A future argon2 version is reported as its own error, because it is an // upgrade problem rather than corruption. future := strings.Replace(valid, fields[2], "v=99", 1) if _, err := VerifyPassword(future, goodPassword); err != ErrIncompatibleVersion { t.Errorf("error for a future version = %v, want ErrIncompatibleVersion", err) } } // The defaults are a security decision, so they are asserted rather than // assumed: OWASP's argon2id recommendation is m=65536 (64 MiB), t=3, p=4. func TestDefaultPasswordParamsMeetOWASP(t *testing.T) { p := DefaultPasswordParams if p.Memory < 64*1024 { t.Errorf("Memory = %d KiB, want at least 65536", p.Memory) } if p.Time < 3 { t.Errorf("Time = %d, want at least 3", p.Time) } if p.Threads < 1 { t.Errorf("Threads = %d, want at least 1", p.Threads) } if p.SaltLength < 16 { t.Errorf("SaltLength = %d, want at least 16", p.SaltLength) } if p.KeyLength < 32 { t.Errorf("KeyLength = %d, want at least 32", p.KeyLength) } } func TestNeedsRehash(t *testing.T) { weak, err := HashPasswordWithParams(goodPassword, testParams) if err != nil { t.Fatalf("hash: %v", err) } if !NeedsRehash(weak, DefaultPasswordParams) { t.Error("a hash below the default cost was not flagged for rehashing") } strong, err := HashPasswordWithParams(goodPassword, DefaultPasswordParams) if err != nil { t.Fatalf("hash: %v", err) } if NeedsRehash(strong, DefaultPasswordParams) { t.Error("a hash at the default cost was flagged for rehashing") } if !NeedsRehash("not-a-hash", DefaultPasswordParams) { t.Error("an unreadable hash should be flagged for rehashing") } } // firstField is used only to report a failure without dumping a whole hash. func firstField(hash string) string { parts := strings.SplitN(hash, "$", 3) if len(parts) < 2 { return hash } return "$" + parts[1] + "$" }