Files
krow_backend/go-api/internal/auth/password_test.go
2026-08-24 13:06:29 +05:30

255 lines
8.6 KiB
Go

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] + "$"
}